Skip to content

Commit 0b4022b

Browse files
os-warrenclaude
andauthored
feat(automation): GET /automation/:name/runs retires cursor and computes hasMore (#19493)
Part of #19543 Clause-②: yes Door ① of three. `GET /api/automation/:name/runs` declared a pagination parameter it never spent, and then reported — as a literal — that there was nothing more to fetch. Both halves are addressed here. ## The ruling, which is the maintainer's call and not this PR's Comment `5754491070` on #19365 records decision batch #204 item 2, ⚠️ and neither that comment nor that card resolves any more — #19365 was removed from the board on 2026-09-21 and GitHub cannot restore a number. The number is kept here rather than re-pointed, because the comment was never on any other card and naming a different one would be false. **The live record is #19543**, the rebuild, which carries this ruling quoted verbatim together with what could not be recovered. The ruling's own durable copy is in this diff: the `reason` field of the D3 entry in `packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts`. letters `C · C · A` per door, maintainer 「204 同意」 2026-09-21. For door ① the ruling reads, verbatim: > `cursor` is retired from `ListRunsRequestSchema`; `limit` stays (it is read > end to end and the Console's flow-runs page sends it today); the engine > reports truncation to the route and **`hasMore` is computed**, never > hard-coded. A (a cursor protocol for a 100-row window) and B (retire cursor > and leave the lie) are ⛔ not taken. ⛔ Not re-adjudicated here. Letter A — building a cursor protocol — is explicitly not taken, so no continuation token is minted and `nextCursor` stays absent. **Why `Part of` and not a closing keyword.** Doors ② (export jobs) and ③ (AI conversations) are ruled but gated on a cloud-repo reading riding #19545 (the rebuild of #19361, which no longer resolves), and the ruling has the seat execute them on that reading's return without re-entering the decision box. A merge that shut the card would strand two-thirds of the ruled work, so the card stays open and the seat re-labels it. The gate `scripts/check-partof-closing-keyword.mjs` is the mechanical half of that, and its RULE 3 is why no sentence here binds a closing keyword to a number at all — not even one written to prevent an auto-close, which is the exact incident that gate exists for. ## The premise was re-measured, and one half of the card's body is false Every reading below was re-taken on `origin/main` at `5e7d83c`, not relayed. | claim | reading | |---|---| | `cursor` declared, never read | **holds** — `ListRunsRequestSchema` declared it; `AutomationEngine.listRuns` never looked at the option; no emit site writes `nextCursor` | | `hasMore` hard-coded | **holds** — `automation.ts` returned `deps.success({ runs, hasMore: false })`, a literal, beside `merged.slice(0, limit)` | | `limit` declared, never read | ⛔ **FALSE** — read end to end | | `.default(20)` unique to the export door | ⛔ **FALSE** — `ListRunsRequestSchema` carries it too | `limit` is read at the boundary (`parseIntegerParam`, with the `1..100` bounds taken off the schema itself), forwarded to `IAutomationService`, and spent by the engine as `RunStore.listHistory`'s window. It is also pinned by live enforcement in `automation-runs-query-validation.test.ts`. **Retiring it would have been a regression, not a narrowing**, and the ruling says the `/packages` parent ruling `5651023067` does not transfer. Both corrections belong on the card's thread, which is the census. ## What "truncated" means at this seam The tempting signal is `runs.length === limit`. It is wrong at exactly one input, and that input is undetectable from the response: **a flow holding exactly `limit` runs produces a window byte-identical to one held by a flow with ten thousand.** Reporting `true` for the first is as wrong as `false` for the second. Only one of the three sources `listRuns` merges was ever capped — the durable history arm, because `RunStore.listHistory(flowName, limit)` takes the window as an argument. The paused arm and the in-memory ring are read in full. So the signal chosen is an **over-read of exactly one row**: the history arm is asked for `limit + 1`, and the merged, filtered, ordered set is compared against `limit`. Overflow means a run matched that this window does not carry. The extra row is dropped by the same `.slice(0, limit)` that was always there, so nothing on the wire widens. ⛔ `RunStore.listHistory`'s signature is deliberately **not** redesigned: over-reading is expressible in the `limit` it already takes, so the truncation signal costs the store contract nothing. Two things `hasMore` deliberately does not mean, both pinned: - ⛔ **not** "retention evicted older runs" — a run the per-flow cap discarded does not exist any more; it is not "more" and no `limit` brings it back. - ⛔ **not** "there is a next page" — nothing mints a cursor. The caller's remedy is a wider `limit`, up to the declared 100. **One honest residual, pre-existing and unchanged.** Under `?status=`, the history arm's window is still the newest `limit + 1` rows of *any* status, because `listHistory` has no status slot and the filter is applied to what comes back. A status-filtered `hasMore: false` therefore means "no further match within the scanned window", not "no further match exists". Pushing the filter down is a store-contract change; the engine's own comment already recorded this for the listing itself, and it is called out in the new test's docblock rather than papered over. ## Behaviour changes on the wire **1. `?cursor=a&cursor=b` answered `400 VALIDATION_FAILED`; it now answers `200` with the key ignored.** This reverses a decision recorded under #7300, which chose to validate the key rather than decide it — the reasoning being that a future cursor implementation must not be the one to discover the type was never enforced. The ruling decides it instead: there will be no cursor implementation on this door, so a 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. The old refusal cases are superseded by cases asserting the opposite on the same inputs — the shape #7359 and #8054 already used on this route's other two parameters. **2. `hasMore` can now be `true`.** A request whose window is shorter than the matching run set receives `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. **3. A service implementing no `listRunsPage` answers `501`** naming the member, never a `200` carrying a guessed `hasMore`. "Absence must be loud" — falling through to the domain's `404` would leave a caller unable to tell "no run listing is mounted here" from "no such flow". The `403` run-read grant runs ahead of the service probe and is unaffected, which is what that gate's own note already required. ## Shape of the change - **spec** — `cursor: retiredKey(RUNS_LIST_CURSOR_REMOVED)`. A tombstone, not a deletion: the request schema is not `.strict()`, so a bare deletion makes Zod silently strip whatever a generated client keeps sending — a clean parse and a parameter that never takes effect, which is this defect re-created one layer down (ADR-0104). The form is copied from the landed sibling (#17667 / PR #19364 — that PR number no longer resolves and has no rebuild, being a merged PR rather than a card; card #17667 resolves and is the live record) rather than invented. - **contract** — new optional `IAutomationService.listRunsPage` returning the exported `RunListResult` (`{ runs, hasMore }`) — the shape `IExportService.listExportJobs` already uses, minus the cursor nothing mints. `cursor` leaves `listRuns`'s options in the same stroke. - **engine** — `listRunsPage` holds the whole method; `listRuns` is its `runs` half. ⭐ One implementation, two projections, so there is no second merge/filter/sort to rot. This is also why ~120 existing `listRuns` call sites across `service-automation`, `plugin-approvals`, `examples/` and `packages/cli` are untouched. - **ADR-0087** — `RETIRED_KEYS_BY_MAJOR[18]` entry plus the D3 semantic entry `automation-runs-cursor-retired`. No D2 conversion: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only. Registered at 18, not 17, per the sibling convention. - **changeset** — `minor` across the three published packages, carrying the ADR-0087 disposition `registered automation-runs-cursor-retired`. - **docs** — `content/docs/automation/flows.mdx`'s REST route table advertised `?cursor` on this route. That row is false once the key is retired, so it now states the retirement, that a request still carrying the key is **ignored rather than refused**, and that `hasMore` is computed with a wider `?limit` as the remedy. Flagged by Docs Drift Check (`5755158989`); the other 10 pages it named document the DATA door's `hasMore` and are true as they stand, so none was edited. Written by the dispatching seat, not the implementer — the implementer's one body write was spent at create. - **SDK** — `@objectstack/client` declared `cursor` and appended `?cursor=` on all three run-list surfaces (`automation.runs.list`, `automation.listRuns`, `client.environment(id).automation.listRuns`). 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 ADR-0104 silent strip the tombstone exists to prevent, one layer down. The option and the emitter are gone from all three, the URL pin is inverted into a three-surface absence pin, and `'@objectstack/client': minor` joins the changeset. Same call the repo made when #6361 retired the notifications `cursor`. Added by the dispatching seat after the at-tier contract review FAILed the previous head on exactly this; the implementer's one body write was spent at create. ## Verification - `automation-runs-query-validation.test.ts`: 48 → **51**, and every assertion that moved is named. Removed: the `#7300` cursor-refusal describe (3 parametrised cases) and 3 `?cursor=` preservation rows — superseded, not deleted, with the replacement asserting the opposite on the same inputs. Added: 6 retirement cases and 3 `hasMore`-relay cases. Changed: the double now serves `listRunsPage`, and `cursor: undefined` left 10 expected options objects. **The `limit` preservation rows are byte-identical otherwise** — the door still forwards the caller's own window, never a widened one, because the over-read lives in the engine. - New `run-list-truncation.test.ts` (14 cases) pins the boundary table — fewer than / **exactly** / more than `limit` — plus a spy proving the store is asked for `limit + 1`. - `pnpm test`: runtime 271 files, service-automation 141 files / 1690 tests. - `pnpm typecheck`: spec, runtime, service-automation — all green, no new `test-typecheck-debt.json` entries. - Derived gate union (`scripts/pm/dispatch-gates.mjs --commands`, reconciled with `--ran`): **112 derived · 110 exit 0 · 2 exit 3 (NOT MEASURED) · 0 unrun**. Exit codes were captured before any pipe. The two are environmental refusals, ⛔ not findings and ⛔ not passes: `check-plugin-teardown-shape --self-test` cannot reach a commit-pinned positive control in a shallow checkout (`--is-shallow-repository` is `true` here; **the gate itself ran, exit 0**), and `check:dual-build-cjs-loads` refuses without a repo-wide build (38 packages carry no `dist/`). CI has both. Two further families initially refused on the same prerequisite class and were converted into real readings by building what they read: `check:skill-examples` (258 prose examples type-check) and `check:type-check-debt` (4 ledger entries re-measured, 53 raw errors, none above its recorded number). ## Serial constraints Declared adjacency from the dispatch: PR #19373 holds `packages/spec/dropped-refinements.baseline.json`, `packages/spec/api-surface/root.json` and `packages/spec/export-origins/root.json`. **This PR moves none of those three** — regeneration landed on the `contracts` shards (`api-surface/contracts.json`, `export-origins/contracts.json`) plus `authorable-surface/api.json`, all disjoint. `origin/main` was merged before this reading and `check:generated` reports all 15 artefacts current. ## Acceptance notes Out of scope, observed, ⛔ not filed and ⛔ not widened into this PR: - **`ListRunsResponseSchema.nextCursor` stays declared and never emitted.** Not a contract violation — an absent optional key promises nothing — so it is not class (b), and minting one is letter A, explicitly not taken. Now commented in place. Whoever takes door ② or ③ touches the same file. - **`GET /automation` (list flows) also ships a literal `hasMore: false`.** Measured, and there it is *true*: the handler returns every name with `total === names.length`, so nothing is withheld. Recorded so the next reader does not read the two literals as the same defect. No card. - **The `?status=` window residual** described above is a real narrowing of what `hasMore: false` can promise. It is pre-existing, it is the engine's own recorded limitation, and closing it is a `RunStore` contract change — the ruling scoped this card to the truncation signal. Deviations from the dispatch's declared file surface, both required by the ruling's own text and reported rather than taken silently: `packages/spec/src/contracts/automation-service.ts` (the ruling's "engine reports truncation to the route" needs the contract member the route calls), and two `packages/runtime` test doubles that stub the run-list service — `http-dispatcher.test.ts` and `automation-run-read-permission-gate.test.ts`. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dc9e29b commit 0b4022b

20 files changed

Lines changed: 1225 additions & 93 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/service-automation': minor
5+
'@objectstack/client': minor
6+
---
7+
8+
feat(automation): `GET /automation/:name/runs` retires `cursor` and computes `hasMore` (#19543)
9+
10+
This door declared a pagination parameter it never spent and then reported, as a
11+
literal, that there was nothing more to fetch. Both halves are closed here, per
12+
the maintainer-approved ruling of 2026-09-21 (decision batch #204 item 2,
13+
letter C of three).
14+
15+
**BREAKING** — `cursor` no longer parses on `ListRunsRequestSchema`, its slot
16+
is gone from `IAutomationService.listRuns`, and `@objectstack/client` no longer
17+
declares or sends it on any of the three run-list surfaces
18+
(`automation.runs.list`, `automation.listRuns`,
19+
`client.environment(id).automation.listRuns`). It was declared on the wire,
20+
*validated* at the boundary, forwarded into the service contract, appended by
21+
the SDK, and read by no implementation. No emit site has ever written the
22+
response half `nextCursor`, and the only ordering this door has is a required
23+
but non-unique `startedAt` timestamp that nothing ever minted a resume point
24+
from — so a caller looping "until the cursor runs out" re-read the first and
25+
only window forever, with no error.
26+
27+
```
28+
FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' })
29+
-> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped
30+
31+
TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' })
32+
-> throws: '`cursor` was removed from GET /api/automation/:name/runs in
33+
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …'
34+
```
35+
36+
`cursor` is a `retiredKey()` tombstone rather than a deletion: the request
37+
schema is not `.strict()`, so a bare deletion would have made Zod silently strip
38+
whatever a generated client kept sending — a clean parse and a parameter that
39+
never takes effect, which is this defect re-created one layer down (ADR-0104).
40+
Writing the key is now a `tsc` error and a parse error carrying the
41+
prescription.
42+
43+
**The SDK is retired in the same stroke, and that is what makes the sentence
44+
above true.** Retiring the key in the schema alone would have left the one
45+
generated client this repo ships typing it `string` and sending it into a route
46+
that no longer reads it — the exact ADR-0104 shape the tombstone exists to
47+
prevent, re-created one layer down, for the channel most callers actually reach
48+
this door through. So the option is gone from all three surfaces and no
49+
`?cursor=` is appended on any of them; an untyped caller cannot smuggle it past
50+
the retired schema either, which is pinned. Same call as when #6361 retired the
51+
notifications `cursor`: the client dropped the option and recorded the removal
52+
in its docblock.
53+
54+
```
55+
FROM client.automation.runs.list('f', { limit: 5, cursor: 'abc' })
56+
-> GET …/automation/f/runs?limit=5&cursor=abc // the key is dropped server-side
57+
58+
TO client.automation.runs.list('f', { limit: 5 })
59+
-> GET …/automation/f/runs?limit=5
60+
// `{ cursor }` is now a TS2353 excess-property error; widen `limit`
61+
// (1..100) and read `hasMore` instead.
62+
```
63+
64+
**⛔ `limit` is NOT retired, and its `.default(20)` stays.** The sibling
65+
`/packages` door retired *its* `limit` alongside `cursor` (#17667) because
66+
nothing read it. That does not transfer, and the ruling says so explicitly: here
67+
`limit` is read end to end — the HTTP boundary enforces the declared `1..100`
68+
range read off the schema itself, the service takes it as an option, and the
69+
engine spends it as the run store's history window. Retiring it would have been
70+
a regression, not a narrowing.
71+
72+
**`hasMore` is now computed, and this is a behaviour change callers can see.**
73+
The door shipped `{ runs, hasMore: false }` with the `false` written as a
74+
literal, beside a list the engine had already cut with `.slice(0, limit)`. A
75+
caller asking for one row of a thousand was handed one row and told that was all
76+
of them. A request whose window is shorter than the matching run set now
77+
receives `hasMore: true` where it previously received `false`; a caller that
78+
read `false` as "this is the whole history" was always wrong and is now told so.
79+
`nextCursor` stays absent — nothing mints one.
80+
81+
Read the new `false` with **one qualification**: unfiltered it is exact, but
82+
under `?status=` it means "no further match inside the window that was scanned"
83+
rather than "none exists", because the durable history source has no status slot
84+
and the window is taken before the filter is applied. Pushing the filter down is
85+
a `RunStore` contract change this card did not scope. The published
86+
`RunListResult.hasMore` docblock and the response schema's own description both
87+
carry that qualification, so a consumer meets it where they meet the field.
88+
89+
**How truncation is established, because the obvious signal is wrong.**
90+
`runs.length === limit` cannot tell a flow holding exactly `limit` runs from one
91+
holding ten thousand; the two windows are byte-identical. So
92+
`AutomationEngine` over-reads its history source by exactly one row and compares
93+
the merged, filtered, ordered set against the caller's window.
94+
`RunStore.listHistory`'s signature is deliberately unchanged — over-reading is
95+
expressible in the `limit` it already takes.
96+
97+
**New:** `IAutomationService.listRunsPage`, an optional member returning
98+
`{ runs, hasMore }` (the shape `IExportService.listExportJobs` already uses,
99+
minus the cursor nothing mints), plus the exported `RunListResult`. The engine
100+
implements it and `listRuns` is its `runs` half, so there is one implementation
101+
and no second copy to rot. A deployment whose automation service does not
102+
implement it answers `501` naming the member, never a `200` carrying a guessed
103+
`hasMore`.
104+
105+
**One strictness regression, stated because it reverses a recorded decision.**
106+
`?cursor=a&cursor=b` used to answer `400 VALIDATION_FAILED` and now answers
107+
`200` with the key ignored, like any other unrecognised query name. #7300
108+
validated the key rather than deciding it, so that a future cursor
109+
implementation would not be the one to discover the type was unenforced; this
110+
ruling decides it instead — there will be no cursor implementation on this
111+
door — so the refusal would be validating a key the contract no longer has.
112+
This route declares no closed query-parameter set, so an unrecognised name has
113+
never been refused here on its own account.
114+
115+
Clause-②: yes
116+
117+
<!-- adr-0087: registered automation-runs-cursor-retired -->

‎content/docs/automation/flows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1842,7 +1842,7 @@ curl -b cookies.txt -X POST \
18421842
| Endpoint | Purpose |
18431843
|:---|:---|
18441844
| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) |
1845-
| `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) |
1845+
| `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) |
18461846
| `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) |
18471847
| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` |
18481848
| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run |

‎content/docs/references/api/automation-api.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ const result = AutomationApiErrorCode.parse(data);
524524
| **name** | `string` | ✅ | Flow machine name (snake_case) |
525525
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Filter by execution status |
526526
| **limit** | `integer` | optional (default: `20`) | Maximum number of runs to return |
527-
| **cursor** | `string` | optional | Cursor for pagination |
527+
| **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. |
528528

529529

530530
---
@@ -570,7 +570,7 @@ const result = AutomationApiErrorCode.parse(data);
570570
| **runs** | `{ id: string; flowName: string; flowVersion?: integer; status: Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| …>; … }[]` | ✅ | Execution run logs |
571571
| **total** | `integer` | optional | Total matching runs |
572572
| **nextCursor** | `string` | optional | Cursor for the next page |
573-
| **hasMore** | `boolean` | ✅ | Whether more runs are available |
573+
| **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. |
574574

575575

576576
---

‎packages/client/src/client.test.ts‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,19 +1425,62 @@ describe('ObjectStackClient.automation', () => {
14251425
expect(result.runs).toHaveLength(1);
14261426
});
14271427

1428-
it('should list runs with pagination options', async () => {
1428+
it('should list runs with a window', async () => {
14291429
const { client, fetchMock } = createMockClient({
14301430
success: true,
14311431
data: { runs: [], hasMore: false },
14321432
});
14331433

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

1444+
it('[#19543] never puts a `cursor` on the query string — on ANY of the three run-list surfaces', async () => {
1445+
// This test used to assert the OPPOSITE — it pinned the URL
1446+
// `…/runs?limit=5&cursor=abc`, i.e. that the SDK produced the key. That
1447+
// is what made the parameter harmful rather than inert: `cursor` was
1448+
// accepted at the boundary and read by nothing, so a caller paginating
1449+
// by the published contract re-read the first window forever with no
1450+
// error. #19543 retires it, and the assertion inverts on the same input.
1451+
//
1452+
// The type surface is the enforced channel — `list({ cursor })` is a
1453+
// TS2353 excess-property error, which a runtime assertion cannot reach.
1454+
// This pins the RUNTIME half, which tsc cannot: an untyped caller
1455+
// (plain JS, a `Record` spread, a hand-built options object) must not
1456+
// smuggle the parameter through. The same shape #6361 left behind one
1457+
// door over.
1458+
//
1459+
// All THREE surfaces are swept, because all three appended it and a
1460+
// caller reaching the door through any of them was equally misled.
1461+
const smuggled = { limit: 5, cursor: 'abc' };
1462+
1463+
const a = createMockClient({ success: true, data: { runs: [], hasMore: false } });
1464+
await a.client.automation.runs.list('my_flow', smuggled as unknown as { limit?: number });
1465+
1466+
const b = createMockClient({ success: true, data: { runs: [], hasMore: false } });
1467+
await b.client.automation.listRuns('my_flow', smuggled as unknown as { limit?: number });
1468+
1469+
const c = createMockClient({ success: true, data: { runs: [], hasMore: false } });
1470+
await c.client.environment('proj-alpha').automation.listRuns(
1471+
'my_flow', smuggled as unknown as { limit?: number },
1472+
);
1473+
1474+
for (const [label, m] of [['runs.list', a], ['listRuns', b], ['environment().listRuns', c]] as const) {
1475+
const url = m.fetchMock.mock.calls[0][0] as string;
1476+
// The over-block guard: the window the caller DID ask for still
1477+
// arrives, so this pins a retirement and not a dead door.
1478+
expect(url, `${label} dropped the limit it was given`).toContain('limit=5');
1479+
expect(url, `${label} still appends a retired cursor`).not.toContain('cursor');
1480+
expect(url, `${label} leaked the cursor value`).not.toContain('abc');
1481+
}
1482+
});
1483+
14411484
it('should get a single run', async () => {
14421485
const { client, fetchMock } = createMockClient({
14431486
success: true,

‎packages/client/src/index.ts‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5534,13 +5534,33 @@ export class ObjectStackClient {
55345534
*/
55355535
runs: {
55365536
/**
5537-
* List execution runs for a flow
5537+
* List execution runs for a flow.
5538+
*
5539+
* Returns the newest `limit` runs — a WINDOW, not a page. The
5540+
* `cursor` parameter was removed in `@objectstack/spec` 17.5.0
5541+
* (#19543): it was appended to the query string here, validated at
5542+
* the boundary and read by nothing beyond it, so a caller
5543+
* paginating by it re-read the first window forever.
5544+
*
5545+
* Omit `limit` to take the server's window (20). The declared range
5546+
* is 1..100, and a value this method SENDS that falls outside it is
5547+
* REFUSED with `400 VALIDATION_FAILED`, never clamped — so raise it
5548+
* deliberately to see further back.
5549+
*
5550+
* ⚠️ `0` and `NaN` are the exception, and they are dropped rather
5551+
* than refused: the guard below is truthy, so a falsy `limit` never
5552+
* leaves the client and the server answers its DEFAULT window
5553+
* instead. `-5`, `1.5` and `101` are truthy, are sent, and are
5554+
* refused. The two `listRuns` surfaces guard on `!= null` and do
5555+
* send `0`.
5556+
*
5557+
* There is no continuation token — read `hasMore` to learn whether
5558+
* the window was short.
55385559
*/
5539-
list: async (flowName: string, options?: { limit?: number; cursor?: string }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => {
5560+
list: async (flowName: string, options?: { limit?: number }): Promise<{ runs: ExecutionLog[]; hasMore: boolean }> => {
55405561
const route = this.getRoute('automation');
55415562
const params = new URLSearchParams();
55425563
if (options?.limit) params.set('limit', String(options.limit));
5543-
if (options?.cursor) params.set('cursor', options.cursor);
55445564
const qs = params.toString();
55455565
const res = await this.fetch(`${this.baseUrl}${route}/${flowName}/runs${qs ? `?${qs}` : ''}`);
55465566
return this.unwrapResponse(res);
@@ -5606,15 +5626,20 @@ export class ObjectStackClient {
56065626
});
56075627
return this.unwrapResponse(res) as Promise<T>;
56085628
},
5609-
/** Alias for `automation.runs.list`. */
5629+
/**
5630+
* Alias for `automation.runs.list`.
5631+
*
5632+
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see that
5633+
* method for the reason. A window, not a page: widen `limit`
5634+
* (1..100, default 20) and read `hasMore`.
5635+
*/
56105636
listRuns: async <T extends { runs: ExecutionLog[]; hasMore: boolean } = { runs: ExecutionLog[]; hasMore: boolean }>(
56115637
flowName: string,
5612-
opts?: { limit?: number; cursor?: string; status?: ExecutionStatus },
5638+
opts?: { limit?: number; status?: ExecutionStatus },
56135639
): Promise<T> => {
56145640
const route = this.getRoute('automation');
56155641
const params = new URLSearchParams();
56165642
if (opts?.limit != null) params.set('limit', String(opts.limit));
5617-
if (opts?.cursor) params.set('cursor', opts.cursor);
56185643
// [#7359] The route's declared `status` filter, now that the boundary
56195644
// honours it instead of dropping it. Until this card the typed client
56205645
// could not send it at all — which is why nothing had tripped over the
@@ -8086,14 +8111,19 @@ export class ScopedEnvironmentClient {
80868111
});
80878112
return this.parent._unwrap<T>(res);
80888113
},
8089-
/** List recent runs for a flow, optionally narrowed to one status. */
8114+
/**
8115+
* List recent runs for a flow, optionally narrowed to one status.
8116+
*
8117+
* `cursor` was removed in `@objectstack/spec` 17.5.0 (#19543) — see
8118+
* `automation.runs.list` for the reason. A window, not a page: widen
8119+
* `limit` (1..100, default 20) and read `hasMore`.
8120+
*/
80908121
listRuns: async <T extends { runs: ExecutionLog[]; hasMore: boolean } = { runs: ExecutionLog[]; hasMore: boolean }>(
80918122
flowName: string,
8092-
opts?: { limit?: number; cursor?: string; status?: ExecutionStatus },
8123+
opts?: { limit?: number; status?: ExecutionStatus },
80938124
): Promise<T> => {
80948125
const params = new URLSearchParams();
80958126
if (opts?.limit != null) params.set('limit', String(opts.limit));
8096-
if (opts?.cursor) params.set('cursor', opts.cursor);
80978127
// [#7359] — see the sibling `listRuns` alias above.
80988128
if (opts?.status) params.set('status', opts.status);
80998129
const qs = params.toString();

0 commit comments

Comments
 (0)