Skip to content

Commit 0cdb57a

Browse files
os-zhuangzhuangjianguoclaude
authored
feat(client): resume + getScreen on the automation SDK surface (#3528) (#3552)
* feat(client): resume + getScreen on the automation SDK surface (#3528) A `type: 'screen'` flow suspends at its `screen` node — `execute()` returns `{ status: 'paused', runId, screen }` and the run waits for input. The dispatcher has served the other half of that contract since ADR-0019 (`POST /automation/:flow/runs/:runId/resume`, `GET .../screen`), but the client SDK's automation surface stopped at getFlow / execute / listRuns / getRun. Any consumer 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 is what dead-ended 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`, plus the approval-style `output` / `branchLabel` the dispatcher already accepts, and returns either the next paused screen of a multi-step wizard or the terminal AutomationResult. - `automation.getScreen(flowName, runId)` returns the screen a paused run is waiting on, so a client that did not launch the run (a reload, another tab, an inbox) can render the pending step before resuming. - Both land on the environment-scoped client as well as the unscoped one. The two dispatcher routes had no test coverage at all; they now cover the `inputs` / `variables` aliasing, approval-style output + branchLabel, the pause-again envelope, the 501 when the service cannot resume, and the ordering guard that keeps `/runs/:runId/screen` from being swallowed by the run lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 * docs(api): show the screen-flow resume round-trip in the client SDK guide (#3528) The SDK page's automation example stopped at `trigger()`, which is exactly the gap the issue reports: a reader had no way to learn that `execute()` on a screen flow returns a paused run, or how to finish it. Adds the execute → paused → resume round-trip and `getScreen()` for a client that did not launch the run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LX9ut3MK3KykE11S9bJmv5 --------- Co-authored-by: Claude <zhuangjianguo@steedos.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent fe67e34 commit 0cdb57a

5 files changed

Lines changed: 283 additions & 0 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
feat(client): `automation.resume()` / `automation.getScreen()` — finish a paused screen flow from the SDK (#3528)
6+
7+
A `type: 'screen'` flow suspends when it reaches a `screen` node: `execute()`
8+
returns `{ status: 'paused', runId, screen }` and the run waits for input. The
9+
second half of that contract — `POST /automation/:flow/runs/:runId/resume`
10+
has shipped in the dispatcher since ADR-0019, but the client SDK's automation
11+
surface stopped at `getFlow` / `execute` / `listRuns` / `getRun`. Anything built
12+
on the SDK could therefore *start* a screen flow and never finish it: the run
13+
stayed suspended and the only way out was hand-rolling the HTTP call. That gap
14+
is what stranded the Console's developer "Flow Runs" test runner, where every
15+
test run of a screen flow orphaned a `paused` row.
16+
17+
- **`automation.resume(flowName, runId, signal?)`** — posts the collected screen
18+
values as `inputs` (applied as bare flow variables), plus the approval-style
19+
`output` / `branchLabel` the dispatcher already accepts. Returns the next
20+
`{ status: 'paused', screen }` of a multi-step wizard, or the terminal
21+
`AutomationResult`.
22+
- **`automation.getScreen(flowName, runId)`** — the screen a paused run is
23+
waiting on, so a client that did not launch the run (a page reload, another
24+
tab, an inbox) can render the pending step before resuming.
25+
- Both are available on the environment-scoped client
26+
(`client.project(id).automation.*`) as well as the unscoped one.
27+
28+
Also covers the two dispatcher routes with tests — the resume and screen paths
29+
had none, including the ordering guard that keeps `/runs/:runId/screen` from
30+
being swallowed by the `/runs/:runId` run lookup.

content/docs/api/client-sdk.mdx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,19 @@ await client.i18n.getFieldLabels('account', 'zh-CN');
319319
// Automation — Trigger workflows and automations
320320
await client.automation.trigger('send_welcome_email', { userId });
321321

322+
// Screen flows pause for user input instead of completing. `execute()` returns
323+
// `{ status: 'paused', runId, screen }`; render the screen, then resume the run
324+
// with the collected values. A wizard pauses again for each further step.
325+
const run = await client.automation.execute('convert_lead', { params: { recordId } });
326+
if (run.status === 'paused') {
327+
await client.automation.resume('convert_lead', run.runId, {
328+
inputs: { account_name: 'Radium Labs' },
329+
});
330+
}
331+
// Re-fetch the pending screen when the client did not launch the run itself
332+
// (a page reload, another tab, an inbox):
333+
await client.automation.getScreen('convert_lead', runId);
334+
322335
// Storage — File upload and management
323336
await client.storage.upload(fileData, 'user');
324337
await client.storage.getDownloadUrl('file-123');

packages/client/src/client.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -852,6 +852,83 @@ describe('ObjectStackClient.automation', () => {
852852
);
853853
});
854854

855+
// ── screen-flow runtime (ADR-0019 durable pause, #3528) ──────────────
856+
857+
it('should resume a paused run with the collected screen input', async () => {
858+
const { client, fetchMock } = createMockClient({
859+
success: true,
860+
data: { success: true, output: {}, durationMs: 12 },
861+
});
862+
863+
const result = await client.automation.resume('my_flow', 'run_1', {
864+
inputs: { new_assignee: 'ada@example.com' },
865+
});
866+
expect(fetchMock).toHaveBeenCalledWith(
867+
'http://localhost:3000/api/v1/automation/my_flow/runs/run_1/resume',
868+
expect.objectContaining({
869+
method: 'POST',
870+
body: JSON.stringify({ inputs: { new_assignee: 'ada@example.com' } }),
871+
}),
872+
);
873+
expect(result.success).toBe(true);
874+
});
875+
876+
it('should resume with an approval branch label and node output', async () => {
877+
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
878+
879+
await client.automation.resume('my_flow', 'run_1', {
880+
output: { comment: 'looks good' },
881+
branchLabel: 'approve',
882+
});
883+
expect(fetchMock).toHaveBeenCalledWith(
884+
'http://localhost:3000/api/v1/automation/my_flow/runs/run_1/resume',
885+
expect.objectContaining({
886+
method: 'POST',
887+
body: JSON.stringify({ output: { comment: 'looks good' }, branchLabel: 'approve' }),
888+
}),
889+
);
890+
});
891+
892+
it('should post an empty signal when resuming with no input', async () => {
893+
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
894+
895+
await client.automation.resume('my flow', 'run 1');
896+
expect(fetchMock).toHaveBeenCalledWith(
897+
'http://localhost:3000/api/v1/automation/my%20flow/runs/run%201/resume',
898+
expect.objectContaining({ method: 'POST', body: '{}' }),
899+
);
900+
});
901+
902+
it('should return the next screen when a multi-step wizard pauses again', async () => {
903+
const { client } = createMockClient({
904+
success: true,
905+
data: {
906+
success: true,
907+
status: 'paused',
908+
runId: 'run_1',
909+
screen: { nodeId: 'step2', title: 'Opportunity', fields: [] },
910+
},
911+
});
912+
913+
const result = await client.automation.resume('my_flow', 'run_1', { inputs: { account_id: 'a1' } });
914+
expect(result.status).toBe('paused');
915+
expect(result.screen.nodeId).toBe('step2');
916+
});
917+
918+
it('should fetch the screen a paused run awaits', async () => {
919+
const { client, fetchMock } = createMockClient({
920+
success: true,
921+
data: { runId: 'run_1', screen: { nodeId: 'collect', fields: [] } },
922+
});
923+
924+
const result = await client.automation.getScreen('my_flow', 'run_1');
925+
expect(fetchMock).toHaveBeenCalledWith(
926+
'http://localhost:3000/api/v1/automation/my_flow/runs/run_1/screen',
927+
expect.any(Object),
928+
);
929+
expect(result.screen.nodeId).toBe('collect');
930+
});
931+
855932
// ==========================================
856933
// capabilities getter
857934
// ==========================================
@@ -1062,6 +1139,23 @@ describe('ScopedProjectClient', () => {
10621139
const scoped = client.project('00000000-0000-0000-0000-000000000001');
10631140
expect(scoped.getProjectId()).toBe('00000000-0000-0000-0000-000000000001');
10641141
});
1142+
1143+
it('prefixes the screen-flow automation.resume / getScreen calls', async () => {
1144+
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
1145+
const scoped = client.project('proj-123');
1146+
1147+
await scoped.automation.resume('my_flow', 'run_1', { inputs: { note: 'ok' } });
1148+
expect(fetchMock).toHaveBeenLastCalledWith(
1149+
'http://localhost:3000/api/v1/environments/proj-123/automation/my_flow/runs/run_1/resume',
1150+
expect.objectContaining({ method: 'POST', body: JSON.stringify({ inputs: { note: 'ok' } }) }),
1151+
);
1152+
1153+
await scoped.automation.getScreen('my_flow', 'run_1');
1154+
expect(fetchMock).toHaveBeenLastCalledWith(
1155+
'http://localhost:3000/api/v1/environments/proj-123/automation/my_flow/runs/run_1/screen',
1156+
expect.any(Object),
1157+
);
1158+
});
10651159
});
10661160

10671161
// ==========================================

packages/client/src/index.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2311,6 +2311,48 @@ export class ObjectStackClient {
23112311
);
23122312
return this.unwrapResponse(res) as Promise<T>;
23132313
},
2314+
/**
2315+
* Resume a run suspended at a `screen` (or `approval`) node — the
2316+
* screen-flow runtime's second half (ADR-0019 durable pause).
2317+
*
2318+
* `execute()` returns `{ status: 'paused', runId, screen }` when a flow
2319+
* reaches a screen node; the collected values go back through here as
2320+
* `inputs` (applied as bare flow variables). The result is either the
2321+
* NEXT `{ status: 'paused', screen }` of a multi-step wizard or the
2322+
* terminal `AutomationResult`. Without this method a paused run can only
2323+
* be finished by hand-rolling the HTTP call (#3528).
2324+
*/
2325+
resume: async <T = any>(
2326+
flowName: string,
2327+
runId: string,
2328+
signal?: {
2329+
/** Screen input values, applied as bare flow variables. */
2330+
inputs?: Record<string, unknown>;
2331+
/** Node output, namespaced under the suspended node's id. */
2332+
output?: Record<string, unknown>;
2333+
/** Out-edge to follow (e.g. an approval's `approve` / `reject`). */
2334+
branchLabel?: string;
2335+
},
2336+
): Promise<T> => {
2337+
const route = this.getRoute('automation');
2338+
const res = await this.fetch(
2339+
`${this.baseUrl}${route}/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/resume`,
2340+
{ method: 'POST', body: JSON.stringify(signal ?? {}) },
2341+
);
2342+
return this.unwrapResponse(res) as Promise<T>;
2343+
},
2344+
/**
2345+
* Fetch the screen a paused run is waiting on — lets a client that did
2346+
* not launch the run (a reload, a different tab, an inbox) render the
2347+
* pending step before calling {@link resume}.
2348+
*/
2349+
getScreen: async <T = any>(flowName: string, runId: string): Promise<T> => {
2350+
const route = this.getRoute('automation');
2351+
const res = await this.fetch(
2352+
`${this.baseUrl}${route}/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/screen`,
2353+
);
2354+
return this.unwrapResponse(res) as Promise<T>;
2355+
},
23142356
};
23152357

23162358
/**
@@ -3644,6 +3686,33 @@ export class ScopedProjectClient {
36443686
);
36453687
return this.parent._unwrap<T>(res);
36463688
},
3689+
/**
3690+
* Resume a run suspended at a `screen` / `approval` node with the collected
3691+
* input (ADR-0019 durable pause). Mirrors the unscoped
3692+
* `client.automation.resume`.
3693+
*/
3694+
resume: async <T = any>(
3695+
flowName: string,
3696+
runId: string,
3697+
signal?: {
3698+
inputs?: Record<string, unknown>;
3699+
output?: Record<string, unknown>;
3700+
branchLabel?: string;
3701+
},
3702+
): Promise<T> => {
3703+
const res = await this.parent._fetch(
3704+
this.url(`/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/resume`),
3705+
{ method: 'POST', body: JSON.stringify(signal ?? {}) },
3706+
);
3707+
return this.parent._unwrap<T>(res);
3708+
},
3709+
/** Fetch the screen a paused run is waiting on. */
3710+
getScreen: async <T = any>(flowName: string, runId: string): Promise<T> => {
3711+
const res = await this.parent._fetch(
3712+
this.url(`/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/screen`),
3713+
);
3714+
return this.parent._unwrap<T>(res);
3715+
},
36473716
};
36483717
}
36493718

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ describe('HttpDispatcher', () => {
168168
listRuns: vi.fn().mockResolvedValue([{ id: 'run_1', status: 'completed' }]),
169169
getRun: vi.fn().mockResolvedValue({ id: 'run_1', status: 'completed' }),
170170
trigger: vi.fn().mockResolvedValue({ success: true }),
171+
resume: vi.fn().mockResolvedValue({ success: true, output: {}, durationMs: 7 }),
172+
// Sync per IAutomationService — `ScreenSpec | null`, not a promise.
173+
getSuspendedScreen: vi.fn().mockReturnValue({ nodeId: 'collect', fields: [] }),
171174
getActionDescriptors: vi.fn().mockReturnValue([
172175
{ type: 'decision', name: 'Decision', category: 'logic', paradigms: ['flow'], source: 'builtin' },
173176
{ type: 'http_request', name: 'HTTP Request', category: 'io', paradigms: ['flow', 'approval'], source: 'builtin' },
@@ -275,6 +278,80 @@ describe('HttpDispatcher', () => {
275278
expect(result.response?.status).toBe(404);
276279
});
277280

281+
// ── screen-flow runtime (ADR-0019 durable pause, #3528) ──────────
282+
it('should resume a paused run via POST /:name/runs/:runId/resume', async () => {
283+
const result = await dispatcher.handleAutomation(
284+
'flow_a/runs/run_1/resume', 'POST', { inputs: { new_assignee: 'ada' } }, { request: {} },
285+
);
286+
expect(result.handled).toBe(true);
287+
expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', {
288+
variables: { new_assignee: 'ada' },
289+
});
290+
expect(result.response?.body?.data?.success).toBe(true);
291+
});
292+
293+
it('should accept `variables` as an alias for `inputs` on resume', async () => {
294+
await dispatcher.handleAutomation(
295+
'flow_a/runs/run_1/resume', 'POST', { variables: { note: 'hi' } }, { request: {} },
296+
);
297+
expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', {
298+
variables: { note: 'hi' },
299+
});
300+
});
301+
302+
it('should forward approval-style output + branchLabel on resume', async () => {
303+
await dispatcher.handleAutomation(
304+
'flow_a/runs/run_1/resume', 'POST',
305+
{ output: { comment: 'ok' }, branchLabel: 'approve' }, { request: {} },
306+
);
307+
expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', {
308+
output: { comment: 'ok' },
309+
branchLabel: 'approve',
310+
});
311+
});
312+
313+
it('should resume with an empty signal when the body carries no input', async () => {
314+
await dispatcher.handleAutomation('flow_a/runs/run_1/resume', 'POST', undefined, { request: {} });
315+
expect(mockAutomationService.resume).toHaveBeenCalledWith('run_1', {});
316+
});
317+
318+
it('should surface the next screen when a resumed run pauses again', async () => {
319+
mockAutomationService.resume.mockResolvedValue({
320+
success: true, status: 'paused', runId: 'run_1',
321+
screen: { nodeId: 'step2', title: 'Confirm', fields: [] },
322+
});
323+
const result = await dispatcher.handleAutomation(
324+
'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, { request: {} },
325+
);
326+
expect(result.response?.body?.data?.status).toBe('paused');
327+
expect(result.response?.body?.data?.screen?.nodeId).toBe('step2');
328+
});
329+
330+
it('should return 501 when the automation service cannot resume', async () => {
331+
delete mockAutomationService.resume;
332+
const result = await dispatcher.handleAutomation(
333+
'flow_a/runs/run_1/resume', 'POST', { inputs: {} }, { request: {} },
334+
);
335+
expect(result.handled).toBe(true);
336+
expect(result.response?.status).toBe(501);
337+
});
338+
339+
it('should get the pending screen via GET /:name/runs/:runId/screen', async () => {
340+
const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, { request: {} });
341+
expect(result.handled).toBe(true);
342+
expect(mockAutomationService.getSuspendedScreen).toHaveBeenCalledWith('run_1');
343+
expect(result.response?.body?.data?.screen?.nodeId).toBe('collect');
344+
// `screen` must NOT be swallowed by the getRun route below it.
345+
expect(mockAutomationService.getRun).not.toHaveBeenCalled();
346+
});
347+
348+
it('should return 404 when the run is not awaiting a screen', async () => {
349+
mockAutomationService.getSuspendedScreen.mockReturnValue(null);
350+
const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, { request: {} });
351+
expect(result.handled).toBe(true);
352+
expect(result.response?.status).toBe(404);
353+
});
354+
278355
it('should handle legacy trigger path POST /trigger/:name', async () => {
279356
const result = await dispatcher.handleAutomation('trigger/flow_a', 'POST', { data: 1 }, { request: {} });
280357
expect(result.handled).toBe(true);

0 commit comments

Comments
 (0)