Skip to content

Commit 1df29df

Browse files
fix(spec): AutomationApiContracts names the served /api/v1/automation paths (#20056)
Fixes #20034 Clause-②: no ## Patch round 1 (head `10b1176328`) Added on top of the reviewed head `4c216561c6` (at-tier review PASS, comment 5824559177). It carries the implementer's own two out-of-scope findings and the reviewer's Clause ② reading: - **ADR-0087 D3 entry `automation-runs-cursor-retired`**: `packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts` `:11`, `:43` and `:72` now name `GET /api/v1/automation/:name/runs`, the path this PR's contract publishes and the dispatcher mounts. `packages/spec/src/migrations/registry.ts` was regenerated with `pnpm --filter @objectstack/spec gen:migration-registry` (not hand-edited; the diff is the same three lines at `:5943`, `:5975` and `:6004`). `gen:upgrade-guide` and `gen:spec-changes` were re-run and changed no bytes, because the entry is in step 18, beyond `PROTOCOL_MAJOR` 17. The text still ships today as data in `MIGRATIONS_BY_MAJOR[18]`, which is why it is corrected now. Open PR #20031 regenerates a different region of `registry.ts`; whichever of the two lands second regenerates. - **Two comments**: `packages/runtime/src/query-param.ts:179` and `packages/services/service-automation/src/run-list-truncation.test.ts:6` now quote the `/api/v1` path. Both are comments only. - **Clause ②**: `.changeset/20034-automation-contract-api-v1-paths.md:9` and this body's line 2 now read `Clause-②: no`, with no arm. This diff adds no key, widens no accepted input and adds no export (`scripts/pm/clause2-line.mjs:70`). The level stays `minor`. - No pending changeset quotes a sentence of the D3 entry. `.changeset/19365-automation-runs-cursor-hasmore.md:117` carries only the registration marker naming the entry's id, and the id is unchanged. So this round needs no further deliberate correction. ## What this changes `AutomationApiContracts` (`@objectstack/spec/api`) declared its nine flow endpoints under `/api/automation`. The dispatcher mounts the automation door at `config.prefix || '/api/v1'` plus `/automation`, and `objectstack serve` passes no prefix, so every declared path answered `404 ENDPOINT_NOT_FOUND` on the default composition (measured on a composed runtime by the #19966 dev). This PR takes remedy 1: the contract moves to the served paths. **The runtime and dispatcher are unchanged.** - `packages/spec/src/api/automation-api.zod.ts`: the nine `path` values, the module's `Base path` line and endpoint list, and every other in-file path quote (section headers, `@example`s, the resume docblock, and the `cursor` tombstone text `ListRunsRequestSchema` raises) move from `/api/automation…` to `/api/v1/automation…`. After the edit the file holds 0 occurrences of `/api/automation` (28 moved, plus the 10 docblock lines rewritten). - `packages/spec/src/api/automation-api.zod.test.ts`: the nine path pins move with the values. - `content/docs/references/api/automation-api.mdx`: regenerated with `pnpm --filter @objectstack/spec gen:docs` (not hand-edited). - `packages/runtime/src/automation-api-contract-mounts.test.ts` (new): the drift pin, below. - `.changeset/20034-automation-contract-api-v1-paths.md` (new): `@objectstack/spec` `minor`. - `.changeset/19365-automation-runs-cursor-hasmore.md`: a deliberate correction of a pending note, below. - Patch round 1: `migrations/entries/semantic/18.automation-runs-cursor-retired.ts` and the regenerated `migrations/registry.ts`, plus comments in `packages/runtime/src/query-param.ts` and `packages/services/service-automation/src/run-list-truncation.test.ts`. ## Reproduction, at base `adbbc5d01e` - Spec: `automation-api.zod.ts:14` `Base path: /api/automation`; `:658`–`:706` nine `path` values under `/api/automation`; the test pinned all nine to themselves (`automation-api.zod.test.ts:803`–`:811`). - Runtime: `dispatcher-plugin.ts:909` `const prefix = config.prefix || '/api/v1';`; `registerAutomationRoutes(base)` mounts `${base}/automation…` (`:1465` onwards), called with `prefix` at `:1746`, and with `${prefix}/environments/:environmentId` at `:1742` / `:1750` when project scoping is on. - Route ledger: `route-ledger.ts:429` `POST /automation` (client `automation.create`) and siblings; the header (`:17`) says to prepend `/api/v1` for the wire path. - CLI: `packages/cli/src/commands/serve.ts:4412` calls `createDispatcherPlugin({ scoping, enforceProjectMembership, observability, rateLimit })`, no `prefix`; scoping defaults to off (`:4340`). ## Consumer search: nothing depends on the unversioned form | candidate | reads the contract's `path`? | verdict | | --- | --- | --- | | `packages/adapters/hono/src/hono.test.ts:453` (`GET /api/automation delegates to dispatch()`) | no | Not a consumer. It drives `createHonoApp` with the adapter's own default `prefix` (`options.prefix \|\| '/api'`, `hono/src/index.ts:303`) against a mocked dispatcher and asserts the dispatcher-internal `/automation`. It never imports the contract. | | `packages/client` | no | Builds automation URLs from discovery or its `/api/v1/automation` convention (`getRoute('automation')`); it never names `AutomationApiContracts`. Two comments name the spec test file `automation-api.zod.test.ts`, not the constant. | | everything else in this repo | no | `AutomationApiContracts` occurs only in its declaring file, its spec test, `api-surface/api.json` (name only) and `export-origins/api.json`. No generator reads the path values. | | objectui at the pinned `.objectui-sha` `62597c588` | no | `git grep -F` at that commit: `AutomationApiContracts` 0 files, `/api/automation` 0 files; positive control `/api/v1/automation` 33 files. | | `objectstack-ai/cloud` and npm consumers | not measured | not checked out here | One served surface does use the unversioned form: a host built with `createHonoApp({ kernel })` and no `prefix` serves the whole dispatcher, automation included, under `/api`. That is a documented adapter default, and it applies to every contract family: under that host every other `*ApiContracts` row (`/api/v1/…`) is off by the same segment. The old automation paths matched it by coincidence, not by design, and no code reads the contract under that host, so remedy 2 does not apply. The changeset says how such a host maps the contract paths. ## Deliberate correction of a pending release note `.changeset/19365-automation-runs-cursor-hasmore.md` (pending, not yet released) quotes the `cursor` tombstone text in its FROM/TO block. That text is one of the path quotes this PR moves, so the note became false. Its line 32 changes from -> throws: '`cursor` was removed from GET /api/automation/:name/runs in to -> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in Nothing else in that note changes. This is the DELIBERATE CORRECTION class that `check-empty-changeset.mjs` names. `skip-changeset` is not applied, and `Check Changeset` stays red **by design**. The same-head at-tier review (comment 5824559177) names the note and judges the changed sentence. No other pending changeset quotes an unversioned automation path. At the base, `git grep -n "/api/automation" -- '.changeset/*.md'` showed only that line. Patch round 1 corrected no further note: no pending changeset quotes the D3 entry's sentences. ## Changeset level `minor`, not declared breaking. The `path` type stays `string`, no accepted input narrows, no method changes, and the old values named paths that no route served on the default composition, so a caller that read the constant gets a working URL now without changing code. This follows the precedent of the `PackageApiContracts.installPackage.path` rebind (`.changeset/18058-install-door-contract-rebind.md`, `minor`, not breaking). The declaration is `Clause-②: no` with no arm, in both this body and the changeset. It answers the reader's question "does this widen an accepted input or grow the public surface?" and the answer here is no: no key added, no accepted input widened, no export grown. It is not `(narrowing)` either, because nothing an author writes is removed. `minor` is valid under `no`: a published constant's value moves, and `patch` is a floor, not a ceiling. The first head declared `yes`, copied from the claim, and the reviewer judged that over-declared. ## The drift pin, and proof that it can fail `packages/runtime/src/automation-api-contract-mounts.test.ts` has two legs: 1. **mount**: it starts `createDispatcherPlugin` with **no** `prefix` (the composition `objectstack serve` builds) on a server that records registrations, and requires every contract `METHOD path` to be one of them. The prefix comes from the plugin's own default, not from a constant in the test. 2. **ledger**: every contract route must be a `route-ledger.ts` row under the documented `/api/v1` wire prefix. The live-mount parity gate probes those rows through the real router. The runtime vitest config aliases `@objectstack/spec/*` to spec **source**, so the spec side of the pin reads `src/`, not a build. The ablation was run on the committed tree (`4c216561c6`) with `scripts/ablation-replace.mjs`, one leg at a time, with restores anchored on `HEAD`: | leg | mutation (landed on disk: anchor 1 → 0, blob moved) | pin result | | --- | --- | --- | | contract | `getRun.path` back to `/api/automation/:name/runs/:runId` | mount red, ledger red (`getRun` named), 1 passed | | dispatcher | default prefix `'/api/v1'` → `'/api/v2'` | mount red (all nine named), 2 passed | | restored | none (blobs equal `HEAD`, `git diff HEAD` empty for both paths) | 3 passed | ## Environment-scoped mount The contract does not carry `/api/v1/environments/:environmentId/automation…`, and this PR does not add it. No `*ApiContracts` map declares the scoped variants. Scoping is one mount-time transformation the dispatcher applies to automation, actions, AI and packages alike, and the client derives scoped URLs from discovery. If the variants are ever declared, that belongs once in a contract shared by all the families, not copied into each map. Note that under `projectResolution: 'required'` none of the nine unscoped paths is mounted (`dispatcher-plugin.required-scoping-mounts.integration.test.ts` pins that). ## Verification (head `10b1176328`, patch round 1) - Tests, each package's full local project, under the verify lock at this head. The lock's verdict is `batch-last-exit 0`: the last part of the batch requires all three suites to exit 0, and the batch printed `SUITES spec=0 service-automation=0 runtime=0`. - `@objectstack/spec`: 532 files, 15648 passed, 2 todo. - `@objectstack/service-automation`: 144 files, 1725 passed. - `@objectstack/runtime` (`--project local`): 277 files, 3896 passed, 1 skipped, including the drift pin's 3 cases. - Build: `turbo run build --filter='./packages/*' --filter='./packages/*/*'` at this head: 72 of 72 tasks succeeded, including the tsup and declaration builds of spec, runtime and service-automation. - Spec generated artifacts: `check:generated` reports "All 15 generated artifacts are up to date". `check:migration-registry` reports "src/migrations/registry.ts is current (242 semantic, 210 retired-key, 183 retired-def)". `check:spec-changes` and `check:upgrade-guide` both report up to date. `check:api-surface` reports "public API surface + factory signatures unchanged", and `check:docs` reports "225 generated files in sync". - Derived gate set, taken after `git fetch origin main` (`dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, merge base `adbbc5d01`, 10 paths): 113 families, 6 more than round 0 (`check:migration-registry`, `check:spec-changes`, `check:upgrade-guide`, `check:future-spec-major`, and `check-tenant-audit-census` with its self-test). All 113 ran, and `--ran` reports "113 run, 0 NOT-MEASURED" with 0 UNRUN. 112 exited 0. `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 on the deliberate correction, as designed. No family answered PREREQUISITE NOT MET this round. - The branch is behind `origin/main` (15 commits at the seat's re-read). Three of those commits regenerated one of this PR's 10 paths, `packages/spec/src/migrations/registry.ts`: #20036 (`0bf85eaae6`), #19909 (`5b9402d89b`) and #19818 (`66960564d9`). `git merge-tree --write-tree` of `origin/main` and this head is clean, and the at-tier re-review measured that #20036's and #19909's hunks do not touch this PR's region (`:5940`–`:6004`). The merge queue's rebuilt generation regenerates the file. The derivation's one changed family input across that range is `scripts/sdui-manifest.record.json`, from #20036. (Corrected by the seat after the at-tier review `5825376693` found the earlier sentence, "None of this PR's 10 paths is touched by those commits", false.) - Clause ② and ADR-0087, run offline with this body as the `pull_request` event: - `check-changeset-no-major --event` prints "✓ This diff introduces no `major` bump." and "✓ LEVEL AXIS: this PR declares clause-② `no`, so no package here is declared to have grown a published surface." (declaration line `Clause-②: no`, no arm). - `check-adr-0087-registration` prints "✓ … this PR adds no declared-breaking changeset (2 non-breaking changeset(s) seen)". This gate reads the Clause ② arm from the changeset body (`readClause2Line(parsed.body)`), not from the PR event. Its verdict is the same with and without `--event`. - Lint, narrowed and proven: all 7 touched TS files are in the eslint population (`--print-config` resolves each). `--no-inline-config --format json` reports 7 files, 0 errors, 0 warnings. `eslint.config.mjs` enables no type-aware linting: all seven `parserOptions` blocks are `{ ecmaVersion, sourceType }` only, with no `project` or `projectService`. So this diff cannot change the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. - Round-0 evidence still stands, and its sources are unchanged in round 1: the ablation above on `4c216561c6`, and the spec and runtime `typecheck` runs, both exit 0. Round 1 changes only string literals (the D3 entry and its registry mirror), comments and one changeset line. The type-check-debt gate re-measured at this head: "4 ledger entr(ies) … none above its recorded number". ## Acceptance notes (observations, not filed) - `RouterConfigSchema` (`spec/src/api/router.zod.ts`) defaults `basePath` to `/api` with `mounts.automation: '/automation'`. That spec-only declaration has no runtime reader in this repo. - The contract lists nine of the 17 routes `registerAutomationRoutes` mounts. The ones not listed are resume, cancel, restore-suspension, screen, actions, connectors, `_status` and the legacy trigger form. - **Every remaining `/api/automation` path at head `10b1176328`, and why it stays** (`git grep -n "/api/automation\b"`, with the `automation-api` file-name hits filtered out): - `.changeset/20034-automation-contract-api-v1-paths.md` `:5` and `:15`-`:20`: the FROM column of this PR's own FROM/TO table. - `packages/adapters/hono/src/hono.test.ts:453`-`:454`: the Hono adapter's own default `prefix` (`/api`), driven against a mocked dispatcher. It does not read the contract. - `packages/runtime/src/automation-api-contract-mounts.test.ts:9`: the pin's docblock, describing the drift it guards. - `packages/spec/scripts/file-description.test.ts:878`-`:937`: synthetic fixtures for the docblock-description extractor. They do not quote the contract. - `packages/spec/src/api/router.test.ts:343`: a custom-mounts fixture for `RouterConfigSchema`. - Released `CHANGELOG.md` entries: `packages/client/CHANGELOG.md:3230`, `packages/runtime/CHANGELOG.md:11589`, `packages/services/service-automation/CHANGELOG.md:5802` and `packages/spec/CHANGELOG.md:32723` quote `GET /api/automation/:name/runs` in the released ExecutionStatus-filter entry. These are release-owned and never edited in a code PR; an amendment would be a dedicated docs-only PR. `packages/spec/CHANGELOG.md:14982` names the file `automation-api.mdx`, not a path. Implemented in session `session_019c3Hi6ZMU1p6m6aA6Bz45d` (claim 5823821835; patch round 1 dispatched by the `domain:spec` seat 4). --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 736c63a commit 1df29df

10 files changed

Lines changed: 221 additions & 67 deletions

File tree

‎.changeset/19365-automation-runs-cursor-hasmore.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ FROM ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' })
2929
-> { name: 'f', limit: 20, cursor: 'n_007' } // forwarded, then dropped
3030
3131
TO ListRunsRequestSchema.parse({ name: 'f', cursor: 'n_007' })
32-
-> throws: '`cursor` was removed from GET /api/automation/:name/runs in
32+
-> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in
3333
@objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …'
3434
```
3535

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`AutomationApiContracts` now names the paths the platform actually serves — `/api/v1/automation…` instead of `/api/automation…` (#20034).
6+
7+
The dispatcher mounts the automation door at its `prefix` plus `/automation`, the prefix defaults to `/api/v1`, and `objectstack serve` passes none. So all nine declared paths answered `404 ENDPOINT_NOT_FOUND` on the default composition while the same requests under `/api/v1/automation` answered `200`, and the generated API reference printed the nine unserved paths as the endpoints. Every other `*ApiContracts` map in `@objectstack/spec/api` already carried `/api/v1`; this one was the only outlier. The runtime is unchanged — only the declaration moves.
8+
9+
Clause-②: no
10+
11+
**What moved on the published surface**
12+
13+
| entry | from | to |
14+
| --- | --- | --- |
15+
| `listFlows` (`GET`), `createFlow` (`POST`) | `/api/automation` | `/api/v1/automation` |
16+
| `getFlow` (`GET`), `updateFlow` (`PUT`), `deleteFlow` (`DELETE`) | `/api/automation/:name` | `/api/v1/automation/:name` |
17+
| `triggerFlow` (`POST`) | `/api/automation/:name/trigger` | `/api/v1/automation/:name/trigger` |
18+
| `toggleFlow` (`POST`) | `/api/automation/:name/toggle` | `/api/v1/automation/:name/toggle` |
19+
| `listRuns` (`GET`) | `/api/automation/:name/runs` | `/api/v1/automation/:name/runs` |
20+
| `getRun` (`GET`) | `/api/automation/:name/runs/:runId` | `/api/v1/automation/:name/runs/:runId` |
21+
22+
The module's `Base path` and endpoint list move with them, and so does the text `ListRunsRequestSchema` raises for a retired `cursor`: it now names `GET /api/v1/automation/:name/runs`.
23+
24+
**Who notices.** A caller that built request URLs from these constants was calling paths nothing served on the default composition; it now reaches the serving door with no code change. A caller that hard-coded one of the old strings should send the `/api/v1/automation…` form. The `path` type is unchanged (`string`), no accepted input narrows, and no method changes.
25+
26+
A host that mounts the dispatcher under a different prefix — `@objectstack/hono`'s `createHonoApp`, whose `prefix` defaults to `/api`, is the in-repo example — serves every contract family under that prefix, so it replaces the leading `/api/v1` of any `*ApiContracts` path, now including these nine. The environment-scoped mount (`/api/v1/environments/:environmentId/automation…`, the only one served under `projectResolution: 'required'`) is not declared here, as it is not in any other contract map.
27+
28+
**Kept from drifting again.** A new test in `@objectstack/runtime` boots the dispatcher plugin with its default prefix and requires every contract route to be one it mounts, and to be a row of the runtime route ledger under the `/api/v1` wire prefix that the live-mount parity gate probes.

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,25 @@ Automation API Protocol
1010
Defines REST CRUD endpoint schemas for managing automation flows,
1111
triggering executions, and querying execution history.
1212

13-
Base path: /api/automation
13+
Base path: /api/v1/automation
14+
15+
The wire paths the platform serves: the dispatcher mounts this door at its
16+
`prefix` (default `/api/v1`, the one `objectstack serve` uses) plus
17+
`/automation`. A drift pin in `@objectstack/runtime`
18+
(`automation-api-contract-mounts.test.ts`) holds every `path` in
19+
`AutomationApiContracts` to that mount table.
1420

1521
**Endpoints**
1622
```
17-
GET /api/automation — List flows
18-
GET /api/automation/:name — Get flow
19-
POST /api/automation — Create flow
20-
PUT /api/automation/:name — Update flow
21-
DELETE /api/automation/:name — Delete flow
22-
POST /api/automation/:name/trigger — Trigger flow execution
23-
POST /api/automation/:name/toggle — Enable/disable flow
24-
GET /api/automation/:name/runs — List execution runs
25-
GET /api/automation/:name/runs/:runId — Get single execution run
23+
GET /api/v1/automation — List flows
24+
GET /api/v1/automation/:name — Get flow
25+
POST /api/v1/automation — Create flow
26+
PUT /api/v1/automation/:name — Update flow
27+
DELETE /api/v1/automation/:name — Delete flow
28+
POST /api/v1/automation/:name/trigger — Trigger flow execution
29+
POST /api/v1/automation/:name/toggle — Enable/disable flow
30+
GET /api/v1/automation/:name/runs — List execution runs
31+
GET /api/v1/automation/:name/runs/:runId — Get single execution run
2632
```
2733

2834
<Callout type="info">
@@ -524,7 +530,7 @@ const result = AutomationApiErrorCode.parse(data);
524530
| **name** | `string` | ✅ | Flow machine name (snake_case) |
525531
| **status** | `Enum<'pending' \| 'running' \| 'paused' \| 'completed' \| 'failed' \| 'cancelled' \| 'timed_out' \| 'retrying' \| 'refused'>` | optional | Filter by execution status |
526532
| **limit** | `integer` | optional (default: `20`) | Maximum number of runs to return |
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. |
533+
| **cursor** | `never` | optional | [REMOVED] `cursor` was removed from GET /api/v1/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. |
528534

529535

530536
---
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #20034 — `AutomationApiContracts` names the wire paths the dispatcher mounts.
5+
*
6+
* ## The drift this pins
7+
*
8+
* The published contract (`@objectstack/spec/api`) declared its nine flow
9+
* endpoints under `/api/automation`, while the dispatcher mounts the automation
10+
* door at `config.prefix || '/api/v1'` plus `/automation`, and
11+
* `objectstack serve` passes no prefix. Every declared path answered
12+
* `404 ENDPOINT_NOT_FOUND` on the composed runtime, and nothing noticed: the
13+
* spec's own test pinned the nine strings to themselves, so it stayed green
14+
* through exactly that drift. Only a test that reads BOTH sides can see it, and
15+
* only this package can import both: the spec has no dependency on the runtime.
16+
*
17+
* ## The two legs
18+
*
19+
* 1. MOUNT — boot `createDispatcherPlugin` with NO `prefix`, the composition
20+
* `objectstack serve` uses, and require every contract `METHOD path` to be a
21+
* route it registered. The prefix is the plugin's own default, never a
22+
* constant written here, so moving either side alone turns this red.
23+
* 2. LEDGER — require every contract route to be a `route-ledger.ts` row under
24+
* the `/api/v1` prefix the ledger header documents. That row is what the
25+
* live-mount parity gate (#7526, `route-ledger-live-mount-parity`) probes
26+
* through the real router, so registration here is carried on to
27+
* reachability there.
28+
*
29+
* ⚠️ What this does not cover: the environment-scoped mount
30+
* (`${prefix}/environments/:environmentId/automation`, the only one registered
31+
* under `projectResolution: 'required'`). The contract declares the unscoped
32+
* paths only, as every other `*ApiContracts` map does.
33+
*/
34+
35+
import { describe, it, expect } from 'vitest';
36+
import { AutomationApiContracts } from '@objectstack/spec/api';
37+
38+
import { createDispatcherPlugin } from './dispatcher-plugin.js';
39+
import { ROUTE_LEDGER } from './route-ledger.js';
40+
41+
/** The wire prefix `route-ledger.ts` documents for its non-`absolute` rows. */
42+
const LEDGER_WIRE_PREFIX = '/api/v1';
43+
44+
/** Records `VERB /path` for every registration, in order; mounts nothing. */
45+
function recordingServer() {
46+
const routes: string[] = [];
47+
const rec = (verb: string) => (path: string, _handler: unknown) => {
48+
routes.push(`${verb} ${path}`);
49+
};
50+
return {
51+
routes,
52+
server: {
53+
get: rec('GET'),
54+
post: rec('POST'),
55+
put: rec('PUT'),
56+
delete: rec('DELETE'),
57+
patch: rec('PATCH'),
58+
},
59+
};
60+
}
61+
62+
function pluginCtx(server: unknown) {
63+
const kernel = {
64+
getService: () => undefined,
65+
getServiceAsync: async () => undefined,
66+
};
67+
return {
68+
getKernel: () => kernel,
69+
getService: (name: string) => (name === 'http.server' ? server : undefined),
70+
environmentId: undefined,
71+
logger: { info() {}, warn() {}, error() {}, debug() {} },
72+
hook: () => {},
73+
on: () => {},
74+
} as any;
75+
}
76+
77+
const CONTRACT_ROUTES = Object.entries(AutomationApiContracts).map(
78+
([key, contract]) => ({ key, route: `${contract.method} ${contract.path}` }),
79+
);
80+
81+
describe('#20034 — AutomationApiContracts ↔ what the dispatcher serves', () => {
82+
it('declares routes at all (a vacuous map would pass both legs)', () => {
83+
expect(CONTRACT_ROUTES.length).toBeGreaterThan(0);
84+
});
85+
86+
it('every contract route is mounted by the dispatcher at its DEFAULT prefix', async () => {
87+
const { server, routes } = recordingServer();
88+
// No `prefix`: this is the composition `objectstack serve` builds.
89+
const plugin = createDispatcherPlugin({ securityHeaders: false });
90+
await plugin.start?.(pluginCtx(server));
91+
92+
const unmounted = CONTRACT_ROUTES.filter(({ route }) => !routes.includes(route));
93+
expect(
94+
unmounted,
95+
'AutomationApiContracts names routes the dispatcher does not mount at its default prefix; '
96+
+ `mounted automation routes: ${routes.filter((r) => r.includes('/automation')).join(', ')}`,
97+
).toEqual([]);
98+
});
99+
100+
it('every contract route is a route-ledger row under the documented `/api/v1` wire prefix', () => {
101+
const ledgerWire = new Set(
102+
ROUTE_LEDGER.map((row) => {
103+
if (row.absolute) return row.route;
104+
const sp = row.route.indexOf(' ');
105+
return `${row.route.slice(0, sp)} ${LEDGER_WIRE_PREFIX}${row.route.slice(sp + 1)}`;
106+
}),
107+
);
108+
const unledgered = CONTRACT_ROUTES.filter(({ route }) => !ledgerWire.has(route));
109+
expect(
110+
unledgered,
111+
'AutomationApiContracts names routes with no route-ledger.ts row under the `/api/v1` wire prefix',
112+
).toEqual([]);
113+
});
114+
});

‎packages/runtime/src/query-param.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export function parseIntegerParam(
176176

177177
/**
178178
* A CLOSED-SET parameter — a filter whose declared values are an enum on the
179-
* wire (`?status=failed` on `GET /api/automation/:name/runs`, whose
179+
* wire (`?status=failed` on `GET /api/v1/automation/:name/runs`, whose
180180
* `ListRunsRequestSchema` bounds it to `ExecutionStatus` itself — the enum
181181
* rather than a copy of its members, so the bound is whatever that vocabulary
182182
* declares rather than a count fixed on the day this line was written. #7359

‎packages/services/service-automation/src/run-list-truncation.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/**
44
* #19543 — `AutomationEngine.listRunsPage` and the truncation boundary.
55
*
6-
* `GET /api/automation/:name/runs` used to answer `{ runs, hasMore: false }`
6+
* `GET /api/v1/automation/:name/runs` used to answer `{ runs, hasMore: false }`
77
* with the `false` written as a literal, beside a list the engine had already
88
* cut with `.slice(0, limit)`. A caller asking for one row of a thousand was
99
* handed one row and told that was all of them, with a `200` and nothing in

‎packages/spec/src/api/automation-api.zod.test.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -800,15 +800,15 @@ describe('AutomationApiContracts', () => {
800800
});
801801

802802
it('should define correct paths', () => {
803-
expect(AutomationApiContracts.listFlows.path).toBe('/api/automation');
804-
expect(AutomationApiContracts.getFlow.path).toBe('/api/automation/:name');
805-
expect(AutomationApiContracts.createFlow.path).toBe('/api/automation');
806-
expect(AutomationApiContracts.updateFlow.path).toBe('/api/automation/:name');
807-
expect(AutomationApiContracts.deleteFlow.path).toBe('/api/automation/:name');
808-
expect(AutomationApiContracts.triggerFlow.path).toBe('/api/automation/:name/trigger');
809-
expect(AutomationApiContracts.toggleFlow.path).toBe('/api/automation/:name/toggle');
810-
expect(AutomationApiContracts.listRuns.path).toBe('/api/automation/:name/runs');
811-
expect(AutomationApiContracts.getRun.path).toBe('/api/automation/:name/runs/:runId');
803+
expect(AutomationApiContracts.listFlows.path).toBe('/api/v1/automation');
804+
expect(AutomationApiContracts.getFlow.path).toBe('/api/v1/automation/:name');
805+
expect(AutomationApiContracts.createFlow.path).toBe('/api/v1/automation');
806+
expect(AutomationApiContracts.updateFlow.path).toBe('/api/v1/automation/:name');
807+
expect(AutomationApiContracts.deleteFlow.path).toBe('/api/v1/automation/:name');
808+
expect(AutomationApiContracts.triggerFlow.path).toBe('/api/v1/automation/:name/trigger');
809+
expect(AutomationApiContracts.toggleFlow.path).toBe('/api/v1/automation/:name/toggle');
810+
expect(AutomationApiContracts.listRuns.path).toBe('/api/v1/automation/:name/runs');
811+
expect(AutomationApiContracts.getRun.path).toBe('/api/v1/automation/:name/runs/:runId');
812812
});
813813

814814
it('should have input and output schemas for all endpoints', () => {

0 commit comments

Comments
 (0)