Skip to content

Commit ac471a0

Browse files
fix(automation)!: getSuspendedScreen reads the durable store, not just the hot cache (#4515) (#4566)
`AutomationEngine.getSuspendedScreen` was synchronous, so it could only ever read the in-memory hot cache — it structurally could not consult the suspended-run store. But `SuspendedRun.screen` IS persisted (`sys_automation_run.screen_json`) and `resume()` cold-reads it back via `loadSuspendedRun` on a cache miss. The result, for a durably suspended screen run after a process restart: `POST …/runs/:runId/resume` worked while `GET …/runs/:runId/screen` returned 404 "No pending screen for run" — the refresh-safe re-fetch failing in exactly the situation it exists for (page refresh, another device). That is the rendering half of ADR-0019's durable-suspend promise, missing while the resuming half shipped. BREAKING: `IAutomationService.getSuspendedScreen(runId)` now returns `Promise<ScreenSpec | null>`. No sync variant remains on the contract; every consumer is migrated in this change (the runtime automation domain route, the contract-checked http-dispatcher mock, three engine test call sites). The engine keeps the hot cache as its fast path and falls through to the store via the same `loadSuspendedRun` that `resume` rehydrates from — one loader, two callers, no duplicated rehydration logic. A run that does not exist, is no longer suspended, or paused at a non-screen node still resolves to `null`, so the route keeps 404-ing for genuinely absent runs. A store outage reads as `null` (this backs a 404); `hasSuspendedRun` remains the strict variant that throws for callers who must tell "gone" from "unknown". Tests: `suspended-screen-durability.test.ts` pins the hot path, the cold-boot cache-miss (the bug), the absent-run and no-screen null cases, the store-outage degradation and the no-store behaviour. `flow-durable-suspend.dogfood.test.ts` adds the end-to-end assertion over a real `stop()` → cold `bootStack`: the second kernel re-fetches the persisted screen with its field contract intact, without consuming the pause. Both fail with the fallback removed. Co-authored-by: Claude <support@objectstack.ai>
1 parent e6e9379 commit ac471a0

11 files changed

Lines changed: 275 additions & 13 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
**BREAKING**: `IAutomationService.getSuspendedScreen(runId)` is now **async** — it returns `Promise<ScreenSpec | null>` instead of `ScreenSpec | null` (#4515).
8+
9+
FROM → TO for anyone calling or implementing it:
10+
11+
```ts
12+
// caller
13+
- const screen = automationService.getSuspendedScreen(runId);
14+
+ const screen = await automationService.getSuspendedScreen(runId);
15+
16+
// implementer
17+
- getSuspendedScreen(runId: string): ScreenSpec | null
18+
+ async getSuspendedScreen(runId: string): Promise<ScreenSpec | null>
19+
```
20+
21+
One-line fix: `await` the call (the enclosing function is almost certainly already `async`), and make any test double resolve rather than return (`mockResolvedValue`, not `mockReturnValue`).
22+
23+
Why it had to change: the method could only ever read the engine's in-memory hot cache, because a synchronous signature cannot consult the durable suspended-run store. `SuspendedRun.screen` *is* persisted (`sys_automation_run.screen_json`) and `resume()` cold-reads it back, so after a process restart a still-suspended screen run could be resumed (`POST …/runs/:runId/resume` → 200) while `GET …/runs/:runId/screen` returned 404 “No pending screen for run” — the refresh-safe re-fetch failing in exactly the situation it exists for (page refresh, another device), and the rendering half of ADR-0019's durable-suspend promise missing while the resuming half shipped.
24+
25+
`AutomationEngine.getSuspendedScreen` now takes the hot cache as its fast path and falls through to the store via the same loader `resume()` rehydrates from. A run that does not exist, is no longer suspended, or paused at a non-screen node still resolves to `null`, so `GET …/runs/:runId/screen` keeps returning 404 for genuinely absent runs. No sync variant of the method remains on the contract.

docs/design/screen-flow-runtime.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface ScreenSpec { nodeId: string; title?: string; description?: string; fie
2626
- **screen executor**: suspend when `waitForInput === true` **or** (`config.fields` non-empty **and** `waitForInput !== false`). When suspending, return `{ success:true, suspend:true, screen: { nodeId, title, description, fields } }` built from `node.config`.
2727
- **suspend plumbing**: `NodeExecutionResult.screen``FlowSuspendSignal.screen``SuspendedRun.screen` → paused `AutomationResult.screen`.
2828
- **resume**: apply `signal.variables` as **bare** variables (`variables.set(name, value)`) in addition to the existing `signal.output` (`${nodeId}.key`). If the continuation suspends at another screen, return that screen (multi-screen wizards).
29-
- `getSuspendedScreen(runId)` getter so HTTP can re-fetch the current screen.
29+
- `async getSuspendedScreen(runId)` getter so HTTP can re-fetch the current screen. Durable (#4515): hot cache first, then the `SuspendedRunStore` via the same loader `resume` rehydrates from, so a screen run that survived a restart renders as well as it resumes.
3030

3131
### HTTP (`runtime/http-dispatcher.ts` `handleAutomation`)
3232
- **Launch**: existing `POST /api/v1/automation/:name/trigger` — when the run pauses at a screen, the response includes `{ status:'paused', runId, screen }`.

packages/qa/dogfood/test/flow-durable-suspend.dogfood.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,42 @@ describe('objectstack verify FLOW: a suspended run survives a real cold boot (#4
291291
expect(JSON.parse(String(rec.variables_json)).noteId).toBe(noteId);
292292
});
293293

294+
it('the cold kernel RE-FETCHES the screen — refresh-safe rendering, not just resuming (#4515)', async () => {
295+
// The rendering half of the same promise, and the half that was missing.
296+
// `GET …/runs/:runId/screen` exists so a user who refreshes the page — or
297+
// picks the flow up on another device — gets the form back. It was backed
298+
// by `AutomationEngine.getSuspendedScreen`, which was SYNCHRONOUS and so
299+
// structurally could only read the in-memory hot cache: after this cold
300+
// boot the run was resumable (the test below) yet its screen 404'd, i.e.
301+
// the route failed in exactly the situation it was built for. Fixed by
302+
// making the contract method async and falling through to the same
303+
// suspended-run store `resume` rehydrates from.
304+
const res = await cold!.apiAs(coldToken, 'GET', `/automation/flow_durable_suspend/runs/${runId}/screen`);
305+
expect(res.status, `screen re-fetch after cold boot: ${await res.clone().text()}`).toBe(200);
306+
const body = (await res.json()) as any;
307+
const payload = body.data ?? body;
308+
expect(payload.runId).toBe(runId);
309+
310+
// The screen served by a kernel that never rendered it must be the screen
311+
// the flow declared — read back out of `screen_json`, field contract intact
312+
// (that contract is what the resume below is validated against, #4477).
313+
const screen = payload.screen;
314+
expect(screen.nodeId).toBe('ask');
315+
expect(screen.fields.map((f: any) => f.name)).toContain('resolution');
316+
expect(screen.fields.find((f: any) => f.name === 'resolution').required).toBe(true);
317+
318+
// Read-only: re-fetching must not consume the pause, or a refresh would
319+
// destroy the very run it is trying to display.
320+
const again = await cold!.apiAs(coldToken, 'GET', `/automation/flow_durable_suspend/runs/${runId}/screen`);
321+
expect(again.status).toBe(200);
322+
const row = await cold!.apiAs(coldToken, 'GET', `/data/sys_automation_run/${runId}`);
323+
expect(row.status).toBe(200);
324+
325+
// A genuinely absent run still 404s — durable ≠ credulous.
326+
const missing = await cold!.apiAs(coldToken, 'GET', '/automation/flow_durable_suspend/runs/run_nope/screen');
327+
expect(missing.status).toBe(404);
328+
});
329+
294330
it('the cold kernel RESUMES the run and takes the right branch', async () => {
295331
// The one assertion #4470 was written for. The second kernel never
296332
// executed a node of this run: it has to rebuild the continuation from
@@ -313,6 +349,12 @@ describe('objectstack verify FLOW: a suspended run survives a real cold boot (#4
313349
const history = await cold!.apiAs(coldToken, 'GET', `/data/sys_automation_run/run_${runId}`);
314350
expect(history.status).toBe(200);
315351
expect((((await history.json()) as any).record ?? {}).status).toBe('completed');
352+
353+
// …and with the suspension consumed there is no screen left to render:
354+
// the durable fallback answers for runs that are SUSPENDED, not for every
355+
// id that was ever suspended (#4515).
356+
const screen = await cold!.apiAs(coldToken, 'GET', `/automation/flow_durable_suspend/runs/${runId}/screen`);
357+
expect(screen.status).toBe(404);
316358
});
317359

318360
it('the resumed result is itself durable — a THIRD boot still reads it', async () => {

packages/runtime/src/domains/automation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str
370370
// (refresh-safe re-fetch for the UI flow-runner).
371371
if (parts[1] === 'runs' && parts[2] && parts[3] === 'screen' && m === 'GET') {
372372
if (typeof automationService.getSuspendedScreen === 'function') {
373-
const screen = automationService.getSuspendedScreen(parts[2]);
373+
const screen = await automationService.getSuspendedScreen(parts[2]);
374374
if (!screen) return { handled: true, response: deps.error('No pending screen for run', 404) };
375375
return { handled: true, response: deps.success({ runId: parts[2], screen }) };
376376
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,11 @@ describe('HttpDispatcher', () => {
188188
listRuns: vi.fn().mockResolvedValue([{ id: 'run_1', status: 'completed' }]),
189189
getRun: vi.fn().mockResolvedValue({ id: 'run_1', status: 'completed' }),
190190
resume: vi.fn().mockResolvedValue({ success: true, output: {}, durationMs: 7 }),
191-
// Sync per IAutomationService — `ScreenSpec | null`, not a promise.
192-
getSuspendedScreen: vi.fn().mockReturnValue({ nodeId: 'collect', fields: [] }),
191+
// ASYNC per IAutomationService (#4515) — `Promise<ScreenSpec | null>`.
192+
// It has to be: a screen re-fetch answers for any genuinely
193+
// suspended run, which after a restart means reading the
194+
// durable suspended-run store, not just the hot cache.
195+
getSuspendedScreen: vi.fn().mockResolvedValue({ nodeId: 'collect', fields: [] }),
193196
getActionDescriptors: vi.fn().mockReturnValue([
194197
{ type: 'decision', name: 'Decision', category: 'logic', paradigms: ['flow'], source: 'builtin' },
195198
{ type: 'http_request', name: 'HTTP Request', category: 'io', paradigms: ['flow', 'approval'], source: 'builtin' },
@@ -444,7 +447,7 @@ describe('HttpDispatcher', () => {
444447
});
445448

446449
it('should return 404 when the run is not awaiting a screen', async () => {
447-
mockAutomationService.getSuspendedScreen.mockReturnValue(null);
450+
mockAutomationService.getSuspendedScreen.mockResolvedValue(null);
448451
const result = await dispatcher.handleAutomation('flow_a/runs/run_1/screen', 'GET', {}, { request: {} });
449452
expect(result.handled).toBe(true);
450453
expect(result.response?.status).toBe(404);

packages/services/service-automation/src/builtin/screen-resume-validation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ describe('screen resume validation (#4477)', () => {
140140
const bad = await engine.resume(runId, { variables: {} });
141141
expect(bad.success).toBe(false);
142142
// The screen is still fetchable…
143-
expect(engine.getSuspendedScreen(runId)?.nodeId).toBe('ask');
143+
expect((await engine.getSuspendedScreen(runId))?.nodeId).toBe('ask');
144144
// …and the legitimate submission still lands.
145145
const good = await engine.resume(runId, { variables: { kind: 'normal' } });
146146
expect(good.success).toBe(true);

packages/services/service-automation/src/builtin/subflow-node.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ describe('subflow node executor', () => {
245245
expect(r2.status).toBe('paused');
246246
expect(r2.runId).toBe(parentRunId); // UI keeps one stable run id
247247
expect(r2.screen).toEqual(s2); // next wizard screen
248-
expect(engine.getSuspendedScreen(parentRunId)).toEqual(s2); // refresh-safe re-fetch
248+
expect(await engine.getSuspendedScreen(parentRunId)).toEqual(s2); // refresh-safe re-fetch
249249

250250
const r3 = await engine.resume(parentRunId, { variables: { other: 'x' } });
251251
expect(r3.success).toBe(true);

packages/services/service-automation/src/engine.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -581,13 +581,13 @@ describe('AutomationEngine', () => {
581581
expect(paused.screen!.fields[0]).toMatchObject({ name: 'new_assignee', required: true, type: 'text' });
582582
expect(captured).toBe('UNSET'); // downstream not run yet
583583
// Re-fetchable for a refreshed client.
584-
expect(engine.getSuspendedScreen(paused.runId!)).toMatchObject({ nodeId: 'collect' });
584+
expect(await engine.getSuspendedScreen(paused.runId!)).toMatchObject({ nodeId: 'collect' });
585585

586586
const done = await engine.resume(paused.runId!, { variables: { new_assignee: 'ada@example.com' } });
587587
expect(done.success).toBe(true);
588588
expect(done.status).toBeUndefined();
589589
expect(captured).toBe('ada@example.com'); // bare var set on resume → downstream read it
590-
expect(engine.getSuspendedScreen(paused.runId!)).toBeNull();
590+
expect(await engine.getSuspendedScreen(paused.runId!)).toBeNull();
591591
});
592592

593593
it('passes a field-less screen straight through (no pause)', async () => {

packages/services/service-automation/src/engine.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3186,9 +3186,20 @@ export class AutomationEngine implements IAutomationService {
31863186
* The screen a paused run is currently waiting on (screen-flow runtime), or
31873187
* `null` if the run isn't suspended / didn't pause at a screen node. Lets a
31883188
* UI flow-runner re-fetch the form after a refresh.
3189+
*
3190+
* Durable (#4515): the hot cache is the fast path, and a miss falls through
3191+
* to the {@link SuspendedRunStore} via the same {@link loadSuspendedRun}
3192+
* that {@link resume} rehydrates from — one loader, two callers. Without
3193+
* that fallback a screen run that survived a restart could be *resumed* but
3194+
* not *rendered*, which is precisely when a refresh-safe re-fetch matters.
3195+
*
3196+
* Best-effort by design: a store outage reads as "no such run" (`null`),
3197+
* matching the 404 this backs. A caller that must distinguish "gone" from
3198+
* "unknown" before writing anything wants {@link hasSuspendedRun}, which
3199+
* throws instead.
31893200
*/
3190-
getSuspendedScreen(runId: string): ScreenSpec | null {
3191-
return this.suspendedRuns.get(runId)?.screen ?? null;
3201+
async getSuspendedScreen(runId: string): Promise<ScreenSpec | null> {
3202+
return (await this.loadSuspendedRun(runId))?.screen ?? null;
31923203
}
31933204

31943205
// ── DAG Traversal Core ──────────────────────────────────

0 commit comments

Comments
 (0)