Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
117 changes: 117 additions & 0 deletions .changeset/19365-automation-runs-cursor-hasmore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
'@objectstack/spec': minor
'@objectstack/runtime': minor
'@objectstack/service-automation': minor
'@objectstack/client': minor
---

feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543)

This door declared a pagination parameter it never spent and then reported, as a
literal, that there was nothing more to fetch. Both halves are closed here, per
the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2,
letter C of three).

**BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot
is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer
declares or sends it on any of the three run-list surfaces
(`automation.runs.list`, `automation.listRuns`,
`client.environment(id).automation.listRuns`). It was declared on the wire,
*validated* at the boundary, forwarded into the service contract, appended by
the SDK, and read by no implementation. No emit site has ever written the
response half `nextCursor`, and the only ordering this door has is a required
but non-unique `startedAt` timestamp that nothing ever minted a resume point
from — so a caller looping "until the cursor runs out" re-read the first and
only window forever, with no error.

```
FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' })
-> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped

TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' })
-> throws: '`cursor` was removed from GET /api/automation/:name/runs in
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …'
```

`cursor` is a `retiredKey()` tombstone rather than a deletion: the request
schema is not `.strict()`, so a bare deletion would have made Zod silently strip
whatever a generated client kept sending — a clean parse and a parameter that
never takes effect, which is this defect re-created one layer down (ADR-0104).
Writing the key is now a `tsc` error and a parse error carrying the
prescription.

**The SDK is retired in the same stroke, and that is what makes the sentence
above true.** Retiring the key in the schema alone would have left the one
generated client this repo ships typing it `string` and sending it into a route
that no longer reads it — the exact ADR-0104 shape the tombstone exists to
prevent, re-created one layer down, for the channel most callers actually reach
this door through. So the option is gone from all three surfaces and no
`?cursor=` is appended on any of them; an untyped caller cannot smuggle it past
the retired schema either, which is pinned. Same call as when #6361 retired the
notifications `cursor`: the client dropped the option and recorded the removal
in its docblock.

```
FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' })
-> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side

TO client.automation.runs.list('f', { limit: 5 })
-> GET …/automation/f/runs?limit=5
// `{ cursor }` is now a TS2353 excess-property error; widen `limit`
// (1..100) and read `hasMore` instead.
```

**⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling
`/packages` door retired *its* `limit` alongside `cursor` (#17667) because
nothing read it. That does not transfer, and the ruling says so explicitly: here
`limit` is read end to end — the HTTP boundary enforces the declared `1..100`
range read off the schema itself, the service takes it as an option, and the
engine spends it as the run store's history window. Retiring it would have been
a regression, not a narrowing.

**`hasMore` is now computed, and this is a behaviour change callers can see.**
The door shipped `{ runs, hasMore: false }` with the `false` written as a
literal, beside a list the engine had already cut with `.slice(0, limit)`. A
caller asking for one row of a thousand was handed one row and told that was all
of them. A request whose window is shorter than the matching run set now
receives `hasMore: true` where it previously received `false`; a caller that
read `false` as "this is the whole history" was always wrong and is now told so.
`nextCursor` stays absent — nothing mints one.

Read the new `false` with **one qualification**: unfiltered it is exact, but
under `?status=` it means "no further match inside the window that was scanned"
rather than "none exists", because the durable history source has no status slot
and the window is taken before the filter is applied. Pushing the filter down is
a `RunStore` contract change this card did not scope. The published
`RunListResult.hasMore` docblock and the response schema's own description both
carry that qualification, so a consumer meets it where they meet the field.

**How truncation is established, because the obvious signal is wrong.**
`runs.length === limit` cannot tell a flow holding exactly `limit` runs from one
holding ten thousand; the two windows are byte-identical. So
`AutomationEngine` over-reads its history source by exactly one row and compares
the merged, filtered, ordered set against the caller's window.
`RunStore.listHistory`'s signature is deliberately unchanged — over-reading is
expressible in the `limit` it already takes.

**New:** `IAutomationService.listRunsPage`, an optional member returning
`{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses,
minus the cursor nothing mints), plus the exported `RunListResult`. The engine
implements it and `listRuns` is its `runs` half, so there is one implementation
and no second copy to rot. A deployment whose automation service does not
implement it answers `501` naming the member, never a `200` carrying a guessed
`hasMore`.

**One strictness regression, stated because it reverses a recorded decision.**
`?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers
`200` with the key ignored, like any other unrecognised query name. #7300
validated the key rather than deciding it, so that a future cursor
implementation would not be the one to discover the type was unenforced; this
ruling decides it instead — there will be no cursor implementation on this
door — so the refusal would be validating a key the contract no longer has.
This route declares no closed query-parameter set, so an unrecognised name has
never been refused here on its own account.

Clause-②: yes

<!-- adr-0087: registered automation-runs-cursor-retired -->
2 changes: 1 addition & 1 deletion content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1842,7 +1842,7 @@ curl -b cookies.txt -X POST \
| Endpoint | Purpose |
|:---|:---|
| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) |
| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`, `?status` — narrow to one execution status; an undeclared value is refused `400 VALIDATION_FAILED`). Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) |
| `GET /api/v1/automation/:name/runs` | List runs (`?limit` — 1–100, default 20, the window and the only way to ask for more; `?status` — narrow to one execution status; an undeclared value is refused `400 VALIDATION_FAILED`). `?cursor` was **removed in `@objectstack/spec` 17.5** (#19543): this door mints no continuation token, so a request still carrying it is ignored rather than refused — it used to answer `400 VALIDATION_FAILED` when repeated. The response `hasMore` is computed from the engine's truncation report rather than the constant `false` it used to be, so widen `?limit` when it is `true` — with `?status=`, a `false` means no further match inside the scanned window rather than none at all, because the window is taken before the filter is applied. `501 NOT_IMPLEMENTED` when the service does not declare `listRunsPage`. Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) |
| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`). Requires read on `sys_automation_run` — see [Observing runs](#observing-runs) |
| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ const result = AutomationApiErrorCode.parse(data);
| **name** | `string` | ✅ | Flow machine name (snake_case) |
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Filter by execution status |
| **limit** | `integer` | optional (default: `20`) | Maximum number of runs to return |
| **cursor** | `string` | optional | Cursor for pagination |
| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/automation/:name/runs in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it was VALIDATED at the boundary and then read by nothing: the option reached the service and the engine never looked at it, no emit site has ever written the response half `nextCursor`, and the only ordering this door has is a required but non-unique `startedAt` timestamp that nothing ever minted a resume point from — so a caller looping "until the cursor runs out" re-read the first and only window forever, with no error. Delete the key. `limit` is the real window and STAYS: it is read end to end (boundary to service to store) and bounded to 1..100, so ask for a wider window instead of a next page. Read the response `hasMore` to learn whether the window was short — it is now COMPUTED from the engine rather than the constant `false` it used to be. |


---
Expand Down Expand Up @@ -570,7 +570,7 @@ const result = AutomationApiErrorCode.parse(data);
| **runs** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| …>; … }[]` | ✅ | Execution run logs |
| **total** | `integer` | optional | Total matching runs |
| **nextCursor** | `string` | optional | Cursor for the next page |
| **hasMore** | `boolean` | ✅ | Whether more runs are available |
| **hasMore** | `boolean` | ✅ | Whether more runs matched than this response carries — widen `limit` to see them. Under `status`, `false` means no further match within the scanned window rather than none at all: the window is taken before the filter is applied. |


---
Expand Down
49 changes: 46 additions & 3 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1425,19 +1425,62 @@ describe('ObjectStackClient.automation', () => {
expect(result.runs).toHaveLength(1);
});

it('should list runs with pagination options', async () => {
it('should list runs with a window', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { runs: [], hasMore: false },
});

await client.automation.runs.list('my_flow', { limit: 5, cursor: 'abc' });
// `limit` is the whole query surface of this door now. It used to be
// pinned here alongside `cursor=abc`; that half moved to the absence
// pin below when #19543 retired the key.
await client.automation.runs.list('my_flow', { limit: 5 });
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:3000/api/v1/automation/my_flow/runs?limit=5&cursor=abc',
'http://localhost:3000/api/v1/automation/my_flow/runs?limit=5',
expect.any(Object),
);
});

it('[#19543] never puts a `cursor` on the query string — on ANY of the three run-list surfaces', async () => {
// This test used to assert the OPPOSITE — it pinned the URL
// `…/runs?limit=5&cursor=abc`, i.e. that the SDK produced the key. That
// is what made the parameter harmful rather than inert: `cursor` was
// accepted at the boundary and read by nothing, so a caller paginating
// by the published contract re-read the first window forever with no
// error. #19543 retires it, and the assertion inverts on the same input.
//
// The type surface is the enforced channel — `list({ cursor })` is a
// TS2353 excess-property error, which a runtime assertion cannot reach.
// This pins the RUNTIME half, which tsc cannot: an untyped caller
// (plain JS, a `Record` spread, a hand-built options object) must not
// smuggle the parameter through. The same shape #6361 left behind one
// door over.
//
// All THREE surfaces are swept, because all three appended it and a
// caller reaching the door through any of them was equally misled.
const smuggled = { limit: 5, cursor: 'abc' };

const a = createMockClient({ success: true, data: { runs: [], hasMore: false } });
await a.client.automation.runs.list('my_flow', smuggled as unknown as { limit?: number });

const b = createMockClient({ success: true, data: { runs: [], hasMore: false } });
await b.client.automation.listRuns('my_flow', smuggled as unknown as { limit?: number });

const c = createMockClient({ success: true, data: { runs: [], hasMore: false } });
await c.client.environment('proj-alpha').automation.listRuns(
'my_flow', smuggled as unknown as { limit?: number },
);

for (const [label, m] of [['runs.list', a], ['listRuns', b], ['environment().listRuns', c]] as const) {
const url = m.fetchMock.mock.calls[0][0] as string;
// The over-block guard: the window the caller DID ask for still
// arrives, so this pins a retirement and not a dead door.
expect(url, `${label} dropped the limit it was given`).toContain('limit=5');
expect(url, `${label} still appends a retired cursor`).not.toContain('cursor');
expect(url, `${label} leaked the cursor value`).not.toContain('abc');
}
});

it('should get a single run', async () => {
const { client, fetchMock } = createMockClient({
success: true,
Expand Down
48 changes: 39 additions & 9 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5534,13 +5534,33 @@ export class ObjectStackClient {
*/
runs: {
/**
* List execution runs for a flow
* List execution runs for a flow.
*
* Returns the newest `limit` runs — a WINDOW, not a page. The
* `cursor` parameter was removed in `@objectstack/spec` 17.5.0
* (#19543): it was appended to the query string here, validated at
* the boundary and read by nothing beyond it, so a caller
* paginating by it re-read the first window forever.
*
* Omit `limit` to take the server's window (20). The declared range
* is 1..100, and a value this method SENDS that falls outside it is
* REFUSED with `400 VALIDATION_FAILED`, never clamped — so raise it
* deliberately to see further back.
*
* ⚠️ `0` and `NaN` are the exception, and they are dropped rather
* than refused: the guard below is truthy, so a falsy `limit` never
* leaves the client and the server answers its DEFAULT window
* instead. `-5`, `1.5` and `101` are truthy, are sent, and are
* refused. The two `listRuns` surfaces guard on `!= null` and do
* send `0`.
*
* There is no continuation token — read `hasMore` to learn whether
* the window was short.
*/
list: async (flowName: string, options?: { limit?: number; cursor?: string }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => {
list: async (flowName: string, options?: { limit?: number }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => {
const route = this.getRoute('automation');
const params = new URLSearchParams();
if (options?.limit) params.set('limit', String(options.limit));
if (options?.cursor) params.set('cursor', options.cursor);
const qs = params.toString();
const res = await this.fetch(`${this.baseUrl}${route}/${flowName}/runs${qs ? `?${qs}` : ''}`);
return this.unwrapResponse(res);
Expand Down Expand Up @@ -5606,15 +5626,20 @@ export class ObjectStackClient {
});
return this.unwrapResponse(res) as Promise<T>;
},
/** Alias for `automation.runs.list`. */
/**
* Alias for `automation.runs.list`.
*
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see that
* method for the reason. A window, not a page: widen `limit`
* (1..100, default 20) and read `hasMore`.
*/
listRuns: async <T extends { runs: ExecutionLog[]; hasMore: boolean } = { runs: ExecutionLog[]; hasMore: boolean }>(
flowName: string,
opts?: { limit?: number; cursor?: string; status?: ExecutionStatus },
opts?: { limit?: number; status?: ExecutionStatus },
): Promise<T> => {
const route = this.getRoute('automation');
const params = new URLSearchParams();
if (opts?.limit != null) params.set('limit', String(opts.limit));
if (opts?.cursor) params.set('cursor', opts.cursor);
// [#7359] The route's declared `status` filter, now that the boundary
// honours it instead of dropping it. Until this card the typed client
// could not send it at all — which is why nothing had tripped over the
Expand Down Expand Up @@ -8086,14 +8111,19 @@ export class ScopedEnvironmentClient {
});
return this.parent._unwrap<T>(res);
},
/** List recent runs for a flow, optionally narrowed to one status. */
/**
* List recent runs for a flow, optionally narrowed to one status.
*
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see
* `automation.runs.list` for the reason. A window, not a page: widen
* `limit` (1..100, default 20) and read `hasMore`.
*/
listRuns: async <T extends { runs: ExecutionLog[]; hasMore: boolean } = { runs: ExecutionLog[]; hasMore: boolean }>(
flowName: string,
opts?: { limit?: number; cursor?: string; status?: ExecutionStatus },
opts?: { limit?: number; status?: ExecutionStatus },
): Promise<T> => {
const params = new URLSearchParams();
if (opts?.limit != null) params.set('limit', String(opts.limit));
if (opts?.cursor) params.set('cursor', opts.cursor);
// [#7359] — see the sibling `listRuns` alias above.
if (opts?.status) params.set('status', opts.status);
const qs = params.toString();
Expand Down
Loading
Loading