diff --git a/README.md b/README.md index 94326e6..e70805f 100644 --- a/README.md +++ b/README.md @@ -111,27 +111,212 @@ await activitysmith.notifications.send({ ## Live Activities -Live Activities come in two UI types, but the lifecycle stays the same: -start the activity, keep the returned `activity_id`, update it as state -changes, then end it when the work is done. +

+ Live Activities example +

+ +ActivitySmith supports two ways to drive Live Activities: + +- Recommended: stream updates with `activitysmith.liveActivities.stream(...)` +- Advanced: manual lifecycle control with `start`, `update`, and `end` + +Use stream updates when you want the easiest, stateless flow. You don't need to +store `activity_id` or manage lifecycle state yourself. Send the latest state +for a stable `streamKey` and ActivitySmith will start or update the Live +Activity for you. When the tracked process is over, call `endStream(...)`. + +Use the manual lifecycle methods when you need direct control over a specific +Live Activity instance. + +Live Activity UI types: + +- `metrics`: best for live operational stats like server CPU and memory, queue depth, or replica lag +- `segmented_progress`: best for step-based workflows like deployments, backups, and ETL pipelines +- `progress`: best for continuous jobs like uploads, reindexes, and long-running migrations tracked as a percentage + +### Recommended: Stream updates + +Use a stable `streamKey` to identify the system or workflow you are tracking, +such as a server, deployment, build pipeline, cron job, or charging session. +This is especially useful for cron jobs and other scheduled tasks where you do +not want to store `activity_id` between runs. + +#### Metrics + +

+ Metrics stream example +

+ +```ts +const status = await activitysmith.liveActivities.stream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, +}); +``` + +#### Segmented progress + +

+ Segmented progress stream example +

+ +```ts +await activitysmith.liveActivities.stream("nightly-backup", { + content_state: { + title: "Nightly Backup", + subtitle: "upload archive", + type: "segmented_progress", + number_of_steps: 3, + current_step: 2, + }, +}); +``` + +#### Progress + +

+ Progress stream example +

+ +```ts +await activitysmith.liveActivities.stream("search-reindex", { + content_state: { + title: "Search Reindex", + subtitle: "catalog-v2", + type: "progress", + percentage: 42, + }, +}); +``` + +Call `stream(...)` again with the same `streamKey` whenever the state changes. + +#### End a stream + +Use this when the tracked process is finished and you no longer want the Live +Activity on devices. `content_state` is optional here; include it if you want +to end the stream with a final state. + +```ts +await activitysmith.liveActivities.endStream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 7, unit: "%" }, + { label: "MEM", value: 38, unit: "%" }, + ], + }, +}); +``` + +If you later send another `stream(...)` request with the same `streamKey`, +ActivitySmith starts a new Live Activity for that stream again. + +Stream responses include an `operation` field: + +- `started`: ActivitySmith started a new Live Activity for this `streamKey` +- `updated`: ActivitySmith updated the current Live Activity +- `rotated`: ActivitySmith ended the previous Live Activity and started a new one +- `noop`: the incoming state matched the current state, so no update was sent +- `paused`: the stream is paused, so no Live Activity was started or updated +- `ended`: returned by `endStream(...)` after the stream is ended + +### Advanced: Manual lifecycle control -- `segmented_progress`: best for jobs tracked in steps -- `progress`: best for jobs tracked as a percentage or numeric range +Use these methods when you want to manage the Live Activity lifecycle yourself. -### Shared flow +#### Shared flow 1. Call `activitysmith.liveActivities.start(...)`. 2. Save the returned `activity_id`. 3. Call `activitysmith.liveActivities.update(...)` as progress changes. 4. Call `activitysmith.liveActivities.end(...)` when the work is finished. +### Metrics Type + +Use `metrics` when you want to keep a small set of live stats visible, such as +server health, queue pressure, or database load. + +#### Start + +

+ Metrics start example +

+ +```ts +const start = await activitysmith.liveActivities.start({ + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, +}); + +const activityId = start.activity_id; +``` + +#### Update + +

+ Metrics update example +

+ +```ts +await activitysmith.liveActivities.update({ + activity_id: activityId, + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 76, unit: "%" }, + { label: "MEM", value: 52, unit: "%" }, + ], + }, +}); +``` + +#### End + +

+ Metrics end example +

+ +```ts +await activitysmith.liveActivities.end({ + activity_id: activityId, + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 7, unit: "%" }, + { label: "MEM", value: 38, unit: "%" }, + ], + auto_dismiss_minutes: 2, + }, +}); +``` + ### Segmented Progress Type Use `segmented_progress` when progress is easier to follow as steps instead of a raw percentage. It fits jobs like backups, deployments, ETL pipelines, and -checklists where "step 2 of 3" is more useful than "67%". -`number_of_steps` is dynamic, so you can increase or decrease it later if the -workflow changes. +checklists where "step 2 of 3" is more useful than "67%". `number_of_steps` is +dynamic, so you can increase or decrease it later if the workflow changes. #### Start @@ -149,7 +334,6 @@ const start = await activitysmith.liveActivities.start({ type: "segmented_progress", color: "yellow", }, - channels: ["devs", "ops"], // Optional }); const activityId = start.activity_id; @@ -167,7 +351,7 @@ await activitysmith.liveActivities.update({ content_state: { title: "Nightly database backup", subtitle: "upload archive", - number_of_steps: 4, + number_of_steps: 3, current_step: 2, }, }); @@ -185,8 +369,8 @@ await activitysmith.liveActivities.end({ content_state: { title: "Nightly database backup", subtitle: "verify restore", - number_of_steps: 4, - current_step: 4, + number_of_steps: 3, + current_step: 3, auto_dismiss_minutes: 2, }, }); @@ -257,25 +441,27 @@ await activitysmith.liveActivities.end({ Just like Actionable Push Notifications, Live Activities can have a button that opens provided URL in a browser or triggers a webhook. Webhooks are executed by the ActivitySmith backend. +#### Open URL action +

- Live Activity with action + Metrics Live Activity with action

-#### Open URL action - ```ts const start = await activitysmith.liveActivities.start({ content_state: { - title: "Deploying payments-api", - subtitle: "Running database migrations", - number_of_steps: 5, - current_step: 3, - type: "segmented_progress", + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 76, unit: "%" }, + { label: "MEM", value: 52, unit: "%" }, + ], }, action: { - title: "Open Workflow", + title: "Open Dashboard", type: "open_url", - url: "https://github.com/acme/payments-api/actions/runs/1234567890", + url: "https://ops.example.com/servers/prod-web-1", }, }); @@ -284,6 +470,10 @@ const activityId = start.activity_id; #### Webhook action +

+ Live Activity with action +

+ ```ts await activitysmith.liveActivities.update({ activity_id: activityId, diff --git a/package-lock.json b/package-lock.json index 1f95263..e1e7b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "activitysmith", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "activitysmith", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "typescript": "^5.3.3", diff --git a/package.json b/package.json index 24fee17..1e38ac6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activitysmith", - "version": "1.0.0", + "version": "1.1.0", "description": "Official ActivitySmith Node.js SDK", "keywords": [ "activitysmith", diff --git a/src/ActivitySmith.ts b/src/ActivitySmith.ts index 0c431b0..d3bb5d9 100644 --- a/src/ActivitySmith.ts +++ b/src/ActivitySmith.ts @@ -11,10 +11,15 @@ type SendInitOverrides = Parameters[0]["liveActivityStartRequest"]; type UpdateRequestBody = Parameters[0]["liveActivityUpdateRequest"]; type EndRequestBody = Parameters[0]["liveActivityEndRequest"]; +type StreamRequestBody = + Parameters[0]["liveActivityStreamRequest"]; +type StreamDeleteRequestBody = + Parameters[0]["liveActivityStreamDeleteRequest"]; type LiveInitOverrides = Parameters[1]; type ChannelTargetInput = { channels?: string[] }; type PushSendRequest = PushRequestBody & { channels?: string[] }; type LiveStartSendRequest = StartRequestBody & { channels?: string[] }; +type LiveStreamSendRequest = StreamRequestBody & { channels?: string[] }; function withTargetChannels( request: T & { channels?: string[] }, @@ -108,6 +113,31 @@ export class LiveActivitiesResource { return this.api.endLiveActivity({ liveActivityEndRequest: request }, initOverrides); } + stream(streamKey: string, request: LiveStreamSendRequest, initOverrides?: LiveInitOverrides) { + return this.api.reconcileLiveActivityStream( + { + streamKey, + liveActivityStreamRequest: withTargetChannels(request), + }, + initOverrides, + ); + } + + endStream( + streamKey: string, + request?: StreamDeleteRequestBody, + initOverrides?: LiveInitOverrides, + ) { + if (request) { + return this.api.endLiveActivityStream( + { streamKey, liveActivityStreamDeleteRequest: request }, + initOverrides, + ); + } + + return this.api.endLiveActivityStream({ streamKey }, initOverrides); + } + // Backward-compatible aliases. startLiveActivity(...args: Parameters) { return this.api.startLiveActivity(...args); @@ -121,6 +151,14 @@ export class LiveActivitiesResource { return this.api.endLiveActivity(...args); } + reconcileLiveActivityStream(...args: Parameters) { + return this.api.reconcileLiveActivityStream(...args); + } + + endLiveActivityStream(...args: Parameters) { + return this.api.endLiveActivityStream(...args); + } + startLiveActivityRaw(...args: Parameters) { return this.api.startLiveActivityRaw(...args); } @@ -132,6 +170,16 @@ export class LiveActivitiesResource { endLiveActivityRaw(...args: Parameters) { return this.api.endLiveActivityRaw(...args); } + + reconcileLiveActivityStreamRaw( + ...args: Parameters + ) { + return this.api.reconcileLiveActivityStreamRaw(...args); + } + + endLiveActivityStreamRaw(...args: Parameters) { + return this.api.endLiveActivityStreamRaw(...args); + } } export class ActivitySmith { diff --git a/tests/resources.test.js b/tests/resources.test.js index 7b1a7e1..d6fa38d 100644 --- a/tests/resources.test.js +++ b/tests/resources.test.js @@ -228,6 +228,78 @@ describe("resource wrappers", () => { expect(startSpy).toHaveBeenCalledWith({ liveActivityStartRequest: payload }, undefined); }); + it("wraps live activity stream payloads for short methods", async () => { + const ActivitySmith = require("../dist/src/index.js"); + const generated = require("../dist/generated/index.js"); + + const streamSpy = vi + .spyOn(generated.LiveActivitiesApi.prototype, "reconcileLiveActivityStream") + .mockResolvedValue({ operation: "started", stream_key: "prod-web-1" }); + const endStreamSpy = vi + .spyOn(generated.LiveActivitiesApi.prototype, "endLiveActivityStream") + .mockResolvedValue({ operation: "ended", stream_key: "prod-web-1" }); + + const client = new ActivitySmith({ apiKey: "test" }); + await client.liveActivities.stream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, + channels: ["ops"], + }); + await client.liveActivities.endStream("prod-web-1"); + + expect(streamSpy).toHaveBeenCalledWith( + { + streamKey: "prod-web-1", + liveActivityStreamRequest: { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, + target: { channels: ["ops"] }, + }, + }, + undefined, + ); + expect(endStreamSpy).toHaveBeenCalledWith({ streamKey: "prod-web-1" }, undefined); + }); + + it("keeps long stream aliases working", async () => { + const ActivitySmith = require("../dist/src/index.js"); + const generated = require("../dist/generated/index.js"); + + const streamSpy = vi + .spyOn(generated.LiveActivitiesApi.prototype, "reconcileLiveActivityStream") + .mockResolvedValue({ operation: "started", stream_key: "prod-web-1" }); + + const client = new ActivitySmith({ apiKey: "test" }); + const request = { + streamKey: "prod-web-1", + liveActivityStreamRequest: { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [{ label: "CPU", value: 9, unit: "%" }], + }, + }, + }; + + await client.liveActivities.reconcileLiveActivityStream(request); + expect(streamSpy).toHaveBeenCalledWith(request); + }); + it("passes through live activity actions for short methods", async () => { const ActivitySmith = require("../dist/src/index.js"); const generated = require("../dist/generated/index.js"); diff --git a/tests/smoke.test.js b/tests/smoke.test.js index e515448..3975e8e 100644 --- a/tests/smoke.test.js +++ b/tests/smoke.test.js @@ -49,9 +49,18 @@ describe("smoke", () => { type: "segmented_progress", }, }); + await client.liveActivities.stream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [{ label: "CPU", value: 9, unit: "%" }], + }, + }); - expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledTimes(3); expect(String(fetchSpy.mock.calls[0][0])).toContain("/push-notification"); expect(String(fetchSpy.mock.calls[1][0])).toContain("/live-activity/start"); + expect(String(fetchSpy.mock.calls[2][0])).toContain("/live-activity/stream/prod-web-1"); }); });