diff --git a/.changeset/automation-client-resume-screen-flow.md b/.changeset/automation-client-resume-screen-flow.md new file mode 100644 index 000000000..b64572d32 --- /dev/null +++ b/.changeset/automation-client-resume-screen-flow.md @@ -0,0 +1,30 @@ +--- +"@objectstack/client": minor +--- + +feat(client): `automation.resume()` / `automation.getScreen()` — finish a paused screen flow from the SDK (#3528) + +A `type: 'screen'` flow suspends when it reaches a `screen` node: `execute()` +returns `{ status: 'paused', runId, screen }` and the run waits for input. The +second half of that contract — `POST /automation/:flow/runs/:runId/resume` — +has shipped in the dispatcher since ADR-0019, but the client SDK's automation +surface stopped at `getFlow` / `execute` / `listRuns` / `getRun`. Anything built +on the SDK could therefore *start* a screen flow and never finish it: the run +stayed suspended and the only way out was hand-rolling the HTTP call. That gap +is what stranded the Console's developer "Flow Runs" test runner, where every +test run of a screen flow orphaned a `paused` row. + +- **`automation.resume(flowName, runId, signal?)`** — posts the collected screen + values as `inputs` (applied as bare flow variables), plus the approval-style + `output` / `branchLabel` the dispatcher already accepts. Returns the next + `{ status: 'paused', screen }` of a multi-step wizard, or the terminal + `AutomationResult`. +- **`automation.getScreen(flowName, runId)`** — the screen a paused run is + waiting on, so a client that did not launch the run (a page reload, another + tab, an inbox) can render the pending step before resuming. +- Both are available on the environment-scoped client + (`client.project(id).automation.*`) as well as the unscoped one. + +Also covers the two dispatcher routes with tests — the resume and screen paths +had none, including the ordering guard that keeps `/runs/:runId/screen` from +being swallowed by the `/runs/:runId` run lookup. diff --git a/content/docs/api/client-sdk.mdx b/content/docs/api/client-sdk.mdx index c7094e533..fdfec3e00 100644 --- a/content/docs/api/client-sdk.mdx +++ b/content/docs/api/client-sdk.mdx @@ -319,6 +319,19 @@ await client.i18n.getFieldLabels('account', 'zh-CN'); // Automation — Trigger workflows and automations await client.automation.trigger('send_welcome_email', { userId }); +// Screen flows pause for user input instead of completing. `execute()` returns +// `{ status: 'paused', runId, screen }`; render the screen, then resume the run +// with the collected values. A wizard pauses again for each further step. +const run = await client.automation.execute('convert_lead', { params: { recordId } }); +if (run.status === 'paused') { + await client.automation.resume('convert_lead', run.runId, { + inputs: { account_name: 'Radium Labs' }, + }); +} +// Re-fetch the pending screen when the client did not launch the run itself +// (a page reload, another tab, an inbox): +await client.automation.getScreen('convert_lead', runId); + // Storage — File upload and management await client.storage.upload(fileData, 'user'); await client.storage.getDownloadUrl('file-123'); diff --git a/packages/client/src/client.test.ts b/packages/client/src/client.test.ts index 8c9abb3b2..5e5f8e6f0 100644 --- a/packages/client/src/client.test.ts +++ b/packages/client/src/client.test.ts @@ -852,6 +852,83 @@ describe('ObjectStackClient.automation', () => { ); }); + // ── screen-flow runtime (ADR-0019 durable pause, #3528) ────────────── + + it('should resume a paused run with the collected screen input', async () => { + const { client, fetchMock } = createMockClient({ + success: true, + data: { success: true, output: {}, durationMs: 12 }, + }); + + const result = await client.automation.resume('my_flow', 'run_1', { + inputs: { new_assignee: 'ada@example.com' }, + }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/automation/my_flow/runs/run_1/resume', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ inputs: { new_assignee: 'ada@example.com' } }), + }), + ); + expect(result.success).toBe(true); + }); + + it('should resume with an approval branch label and node output', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { success: true } }); + + await client.automation.resume('my_flow', 'run_1', { + output: { comment: 'looks good' }, + branchLabel: 'approve', + }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/automation/my_flow/runs/run_1/resume', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ output: { comment: 'looks good' }, branchLabel: 'approve' }), + }), + ); + }); + + it('should post an empty signal when resuming with no input', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { success: true } }); + + await client.automation.resume('my flow', 'run 1'); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/automation/my%20flow/runs/run%201/resume', + expect.objectContaining({ method: 'POST', body: '{}' }), + ); + }); + + it('should return the next screen when a multi-step wizard pauses again', async () => { + const { client } = createMockClient({ + success: true, + data: { + success: true, + status: 'paused', + runId: 'run_1', + screen: { nodeId: 'step2', title: 'Opportunity', fields: [] }, + }, + }); + + const result = await client.automation.resume('my_flow', 'run_1', { inputs: { account_id: 'a1' } }); + expect(result.status).toBe('paused'); + expect(result.screen.nodeId).toBe('step2'); + }); + + it('should fetch the screen a paused run awaits', async () => { + const { client, fetchMock } = createMockClient({ + success: true, + data: { runId: 'run_1', screen: { nodeId: 'collect', fields: [] } }, + }); + + const result = await client.automation.getScreen('my_flow', 'run_1'); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:3000/api/v1/automation/my_flow/runs/run_1/screen', + expect.any(Object), + ); + expect(result.screen.nodeId).toBe('collect'); + }); + // ========================================== // capabilities getter // ========================================== @@ -1062,6 +1139,23 @@ describe('ScopedProjectClient', () => { const scoped = client.project('00000000-0000-0000-0000-000000000001'); expect(scoped.getProjectId()).toBe('00000000-0000-0000-0000-000000000001'); }); + + it('prefixes the screen-flow automation.resume / getScreen calls', async () => { + const { client, fetchMock } = createMockClient({ success: true, data: { success: true } }); + const scoped = client.project('proj-123'); + + await scoped.automation.resume('my_flow', 'run_1', { inputs: { note: 'ok' } }); + expect(fetchMock).toHaveBeenLastCalledWith( + 'http://localhost:3000/api/v1/environments/proj-123/automation/my_flow/runs/run_1/resume', + expect.objectContaining({ method: 'POST', body: JSON.stringify({ inputs: { note: 'ok' } }) }), + ); + + await scoped.automation.getScreen('my_flow', 'run_1'); + expect(fetchMock).toHaveBeenLastCalledWith( + 'http://localhost:3000/api/v1/environments/proj-123/automation/my_flow/runs/run_1/screen', + expect.any(Object), + ); + }); }); // ========================================== diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 62b88672f..843e26168 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2311,6 +2311,48 @@ export class ObjectStackClient { ); return this.unwrapResponse(res) as Promise; }, + /** + * Resume a run suspended at a `screen` (or `approval`) node — the + * screen-flow runtime's second half (ADR-0019 durable pause). + * + * `execute()` returns `{ status: 'paused', runId, screen }` when a flow + * reaches a screen node; the collected values go back through here as + * `inputs` (applied as bare flow variables). The result is either the + * NEXT `{ status: 'paused', screen }` of a multi-step wizard or the + * terminal `AutomationResult`. Without this method a paused run can only + * be finished by hand-rolling the HTTP call (#3528). + */ + resume: async ( + flowName: string, + runId: string, + signal?: { + /** Screen input values, applied as bare flow variables. */ + inputs?: Record; + /** Node output, namespaced under the suspended node's id. */ + output?: Record; + /** Out-edge to follow (e.g. an approval's `approve` / `reject`). */ + branchLabel?: string; + }, + ): Promise => { + const route = this.getRoute('automation'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/resume`, + { method: 'POST', body: JSON.stringify(signal ?? {}) }, + ); + return this.unwrapResponse(res) as Promise; + }, + /** + * Fetch the screen a paused run is waiting on — lets a client that did + * not launch the run (a reload, a different tab, an inbox) render the + * pending step before calling {@link resume}. + */ + getScreen: async (flowName: string, runId: string): Promise => { + const route = this.getRoute('automation'); + const res = await this.fetch( + `${this.baseUrl}${route}/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/screen`, + ); + return this.unwrapResponse(res) as Promise; + }, }; /** @@ -3644,6 +3686,33 @@ export class ScopedProjectClient { ); return this.parent._unwrap(res); }, + /** + * Resume a run suspended at a `screen` / `approval` node with the collected + * input (ADR-0019 durable pause). Mirrors the unscoped + * `client.automation.resume`. + */ + resume: async ( + flowName: string, + runId: string, + signal?: { + inputs?: Record; + output?: Record; + branchLabel?: string; + }, + ): Promise => { + const res = await this.parent._fetch( + this.url(`/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/resume`), + { method: 'POST', body: JSON.stringify(signal ?? {}) }, + ); + return this.parent._unwrap(res); + }, + /** Fetch the screen a paused run is waiting on. */ + getScreen: async (flowName: string, runId: string): Promise => { + const res = await this.parent._fetch( + this.url(`/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/screen`), + ); + return this.parent._unwrap(res); + }, }; } diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index 8b332a296..a7921a01d 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -168,6 +168,9 @@ describe('HttpDispatcher', () => { listRuns: vi.fn().mockResolvedValue([{ id: 'run_1', status: 'completed' }]), getRun: vi.fn().mockResolvedValue({ id: 'run_1', status: 'completed' }), trigger: vi.fn().mockResolvedValue({ success: true }), + resume: vi.fn().mockResolvedValue({ success: true, output: {}, durationMs: 7 }), + // Sync per IAutomationService — `ScreenSpec | null`, not a promise. + getSuspendedScreen: vi.fn().mockReturnValue({ nodeId: 'collect', fields: [] }), getActionDescriptors: vi.fn().mockReturnValue([ { type: 'decision', name: 'Decision', category: 'logic', paradigms: ['flow'], source: 'builtin' }, { type: 'http_request', name: 'HTTP Request', category: 'io', paradigms: ['flow', 'approval'], source: 'builtin' }, @@ -275,6 +278,80 @@ describe('HttpDispatcher', () => { expect(result.response?.status).toBe(404); }); + // ── screen-flow runtime (ADR-0019 durable pause, #3528) ────────── + it('should resume a paused run via POST /:name/runs/:runId/resume', async () => { + const result = await dispatcher.handleAutomation( + 'flow_a/runs/run_1/resume', 'POST', { inputs: { new_assignee: 'ada' } }, { request: {} }, + ); + expect(result.handled).toBe(true); + expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { + variables: { new_assignee: 'ada' }, + }); + expect(result.response?.body?.data?.success).toBe(true); + }); + + it('should accept `variables` as an alias for `inputs` on resume', async () => { + await dispatcher.handleAutomation( + 'flow_a/runs/run_1/resume', 'POST', { variables: { note: 'hi' } }, { request: {} }, + ); + expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { + variables: { note: 'hi' }, + }); + }); + + it('should forward approval-style output + branchLabel on resume', async () => { + await dispatcher.handleAutomation( + 'flow_a/runs/run_1/resume', 'POST', + { output: { comment: 'ok' }, branchLabel: 'approve' }, { request: {} }, + ); + expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', { + output: { comment: 'ok' }, + branchLabel: 'approve', + }); + }); + + it('should resume with an empty signal when the body carries no input', async () => { + await dispatcher.handleAutomation('flow_a/runs/run_1/resume', 'POST', undefined, { request: {} }); + expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', {}); + }); + + it('should surface the next screen when a resumed run pauses again', async () => { + mockAutomationService.resume.mockResolvedValue({ + success: true, status: 'paused', runId: 'run_1', + screen: { nodeId: 'step2', title: 'Confirm', fields: [] }, + }); + const result = await dispatcher.handleAutomation( + 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, { request: {} }, + ); + expect(result.response?.body?.data?.status).toBe('paused'); + expect(result.response?.body?.data?.screen?.nodeId).toBe('step2'); + }); + + it('should return 501 when the automation service cannot resume', async () => { + delete mockAutomationService.resume; + const result = await dispatcher.handleAutomation( + 'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, { request: {} }, + ); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(501); + }); + + it('should get the pending screen via GET /:name/runs/:runId/screen', async () => { + const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(mockAutomationService.getSuspendedScreen).toHaveBeenCalledWith('run_1'); + expect(result.response?.body?.data?.screen?.nodeId).toBe('collect'); + // `screen` must NOT be swallowed by the getRun route below it. + expect(mockAutomationService.getRun).not.toHaveBeenCalled(); + }); + + it('should return 404 when the run is not awaiting a screen', async () => { + mockAutomationService.getSuspendedScreen.mockReturnValue(null); + const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, { request: {} }); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(404); + }); + it('should handle legacy trigger path POST /trigger/:name', async () => { const result = await dispatcher.handleAutomation('trigger/flow_a', 'POST', { data: 1 }, { request: {} }); expect(result.handled).toBe(true);