Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/automation-client-resume-screen-flow.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions content/docs/api/client-sdk.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
94 changes: 94 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ==========================================
Expand Down Expand Up @@ -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),
);
});
});

// ==========================================
Expand Down
69 changes: 69 additions & 0 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2311,6 +2311,48 @@ export class ObjectStackClient {
);
return this.unwrapResponse(res) as Promise<T>;
},
/**
* 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 <T = any>(
flowName: string,
runId: string,
signal?: {
/** Screen input values, applied as bare flow variables. */
inputs?: Record<string, unknown>;
/** Node output, namespaced under the suspended node's id. */
output?: Record<string, unknown>;
/** Out-edge to follow (e.g. an approval's `approve` / `reject`). */
branchLabel?: string;
},
): Promise<T> => {
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<T>;
},
/**
* 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 <T = any>(flowName: string, runId: string): Promise<T> => {
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<T>;
},
};

/**
Expand Down Expand Up @@ -3644,6 +3686,33 @@ export class ScopedProjectClient {
);
return this.parent._unwrap<T>(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 <T = any>(
flowName: string,
runId: string,
signal?: {
inputs?: Record<string, unknown>;
output?: Record<string, unknown>;
branchLabel?: string;
},
): Promise<T> => {
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<T>(res);
},
/** Fetch the screen a paused run is waiting on. */
getScreen: async <T = any>(flowName: string, runId: string): Promise<T> => {
const res = await this.parent._fetch(
this.url(`/automation/${encodeURIComponent(flowName)}/runs/${encodeURIComponent(runId)}/screen`),
);
return this.parent._unwrap<T>(res);
},
};
}

Expand Down
77 changes: 77 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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);
Expand Down