From f4312f8eeb475220c228728503eb199139670cfc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:24:47 +0000 Subject: [PATCH 1/3] fix(spec): AutomationApiContracts names the served /api/v1/automation paths The nine flow endpoints were declared under /api/automation while the dispatcher mounts the automation door only at its prefix (default /api/v1) plus /automation, so every declared path answered 404 on the default composition. The nine path values, the Base path docblock, the endpoint list and every in-file path quote (including the retired-cursor tombstone text) move to /api/v1/automation; the spec pin moves with them. A runtime test boots the dispatcher plugin with its default prefix and requires every contract route to be mounted and to be a route-ledger row under the documented /api/v1 wire prefix. The pending runs-cursor changeset quoted the tombstone text and is corrected to the new path. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .../19365-automation-runs-cursor-hasmore.md | 2 +- .../20034-automation-contract-api-v1-paths.md | 28 +++++ .../automation-api-contract-mounts.test.ts | 114 ++++++++++++++++++ .../spec/src/api/automation-api.zod.test.ts | 18 +-- packages/spec/src/api/automation-api.zod.ts | 82 +++++++------ 5 files changed, 196 insertions(+), 48 deletions(-) create mode 100644 .changeset/20034-automation-contract-api-v1-paths.md create mode 100644 packages/runtime/src/automation-api-contract-mounts.test.ts diff --git a/.changeset/19365-automation-runs-cursor-hasmore.md b/.changeset/19365-automation-runs-cursor-hasmore.md index 232d8315a33..2d6e29a9924 100644 --- a/.changeset/19365-automation-runs-cursor-hasmore.md +++ b/.changeset/19365-automation-runs-cursor-hasmore.md @@ -29,7 +29,7 @@ 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 + -> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) …' ``` diff --git a/.changeset/20034-automation-contract-api-v1-paths.md b/.changeset/20034-automation-contract-api-v1-paths.md new file mode 100644 index 00000000000..4d8758fc8c7 --- /dev/null +++ b/.changeset/20034-automation-contract-api-v1-paths.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +`AutomationApiContracts` now names the paths the platform actually serves — `/api/v1/automation…` instead of `/api/automation…` (#20034). + +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. + +Clause-②: yes + +**What moved on the published surface** + +| entry | from | to | +| --- | --- | --- | +| `listFlows` (`GET`), `createFlow` (`POST`) | `/api/automation` | `/api/v1/automation` | +| `getFlow` (`GET`), `updateFlow` (`PUT`), `deleteFlow` (`DELETE`) | `/api/automation/:name` | `/api/v1/automation/:name` | +| `triggerFlow` (`POST`) | `/api/automation/:name/trigger` | `/api/v1/automation/:name/trigger` | +| `toggleFlow` (`POST`) | `/api/automation/:name/toggle` | `/api/v1/automation/:name/toggle` | +| `listRuns` (`GET`) | `/api/automation/:name/runs` | `/api/v1/automation/:name/runs` | +| `getRun` (`GET`) | `/api/automation/:name/runs/:runId` | `/api/v1/automation/:name/runs/:runId` | + +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`. + +**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. + +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. + +**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. diff --git a/packages/runtime/src/automation-api-contract-mounts.test.ts b/packages/runtime/src/automation-api-contract-mounts.test.ts new file mode 100644 index 00000000000..14684b77493 --- /dev/null +++ b/packages/runtime/src/automation-api-contract-mounts.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #20034 — `AutomationApiContracts` names the wire paths the dispatcher mounts. + * + * ## The drift this pins + * + * The published contract (`@objectstack/spec/api`) declared its nine flow + * endpoints under `/api/automation`, while the dispatcher mounts the automation + * door at `config.prefix || '/api/v1'` plus `/automation`, and + * `objectstack serve` passes no prefix. Every declared path answered + * `404 ENDPOINT_NOT_FOUND` on the composed runtime, and nothing noticed: the + * spec's own test pinned the nine strings to themselves, so it stayed green + * through exactly that drift. Only a test that reads BOTH sides can see it, and + * only this package can import both: the spec has no dependency on the runtime. + * + * ## The two legs + * + * 1. MOUNT — boot `createDispatcherPlugin` with NO `prefix`, the composition + * `objectstack serve` uses, and require every contract `METHOD path` to be a + * route it registered. The prefix is the plugin's own default, never a + * constant written here, so moving either side alone turns this red. + * 2. LEDGER — require every contract route to be a `route-ledger.ts` row under + * the `/api/v1` prefix the ledger header documents. That row is what the + * live-mount parity gate (#7526, `route-ledger-live-mount-parity`) probes + * through the real router, so registration here is carried on to + * reachability there. + * + * ⚠️ What this does not cover: the environment-scoped mount + * (`${prefix}/environments/:environmentId/automation`, the only one registered + * under `projectResolution: 'required'`). The contract declares the unscoped + * paths only, as every other `*ApiContracts` map does. + */ + +import { describe, it, expect } from 'vitest'; +import { AutomationApiContracts } from '@objectstack/spec/api'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { ROUTE_LEDGER } from './route-ledger.js'; + +/** The wire prefix `route-ledger.ts` documents for its non-`absolute` rows. */ +const LEDGER_WIRE_PREFIX = '/api/v1'; + +/** Records `VERB /path` for every registration, in order; mounts nothing. */ +function recordingServer() { + const routes: string[] = []; + const rec = (verb: string) => (path: string, _handler: unknown) => { + routes.push(`${verb} ${path}`); + }; + return { + routes, + server: { + get: rec('GET'), + post: rec('POST'), + put: rec('PUT'), + delete: rec('DELETE'), + patch: rec('PATCH'), + }, + }; +} + +function pluginCtx(server: unknown) { + const kernel = { + getService: () => undefined, + getServiceAsync: async () => undefined, + }; + return { + getKernel: () => kernel, + getService: (name: string) => (name === 'http.server' ? server : undefined), + environmentId: undefined, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + hook: () => {}, + on: () => {}, + } as any; +} + +const CONTRACT_ROUTES = Object.entries(AutomationApiContracts).map( + ([key, contract]) => ({ key, route: `${contract.method} ${contract.path}` }), +); + +describe('#20034 — AutomationApiContracts ↔ what the dispatcher serves', () => { + it('declares routes at all (a vacuous map would pass both legs)', () => { + expect(CONTRACT_ROUTES.length).toBeGreaterThan(0); + }); + + it('every contract route is mounted by the dispatcher at its DEFAULT prefix', async () => { + const { server, routes } = recordingServer(); + // No `prefix`: this is the composition `objectstack serve` builds. + const plugin = createDispatcherPlugin({ securityHeaders: false }); + await plugin.start?.(pluginCtx(server)); + + const unmounted = CONTRACT_ROUTES.filter(({ route }) => !routes.includes(route)); + expect( + unmounted, + 'AutomationApiContracts names routes the dispatcher does not mount at its default prefix; ' + + `mounted automation routes: ${routes.filter((r) => r.includes('/automation')).join(', ')}`, + ).toEqual([]); + }); + + it('every contract route is a route-ledger row under the documented `/api/v1` wire prefix', () => { + const ledgerWire = new Set( + ROUTE_LEDGER.map((row) => { + if (row.absolute) return row.route; + const sp = row.route.indexOf(' '); + return `${row.route.slice(0, sp)} ${LEDGER_WIRE_PREFIX}${row.route.slice(sp + 1)}`; + }), + ); + const unledgered = CONTRACT_ROUTES.filter(({ route }) => !ledgerWire.has(route)); + expect( + unledgered, + 'AutomationApiContracts names routes with no route-ledger.ts row under the `/api/v1` wire prefix', + ).toEqual([]); + }); +}); diff --git a/packages/spec/src/api/automation-api.zod.test.ts b/packages/spec/src/api/automation-api.zod.test.ts index 9f1ac48e5e3..06ffab6aada 100644 --- a/packages/spec/src/api/automation-api.zod.test.ts +++ b/packages/spec/src/api/automation-api.zod.test.ts @@ -800,15 +800,15 @@ describe('AutomationApiContracts', () => { }); it('should define correct paths', () => { - expect(AutomationApiContracts.listFlows.path).toBe('/api/automation'); - expect(AutomationApiContracts.getFlow.path).toBe('/api/automation/:name'); - expect(AutomationApiContracts.createFlow.path).toBe('/api/automation'); - expect(AutomationApiContracts.updateFlow.path).toBe('/api/automation/:name'); - expect(AutomationApiContracts.deleteFlow.path).toBe('/api/automation/:name'); - expect(AutomationApiContracts.triggerFlow.path).toBe('/api/automation/:name/trigger'); - expect(AutomationApiContracts.toggleFlow.path).toBe('/api/automation/:name/toggle'); - expect(AutomationApiContracts.listRuns.path).toBe('/api/automation/:name/runs'); - expect(AutomationApiContracts.getRun.path).toBe('/api/automation/:name/runs/:runId'); + expect(AutomationApiContracts.listFlows.path).toBe('/api/v1/automation'); + expect(AutomationApiContracts.getFlow.path).toBe('/api/v1/automation/:name'); + expect(AutomationApiContracts.createFlow.path).toBe('/api/v1/automation'); + expect(AutomationApiContracts.updateFlow.path).toBe('/api/v1/automation/:name'); + expect(AutomationApiContracts.deleteFlow.path).toBe('/api/v1/automation/:name'); + expect(AutomationApiContracts.triggerFlow.path).toBe('/api/v1/automation/:name/trigger'); + expect(AutomationApiContracts.toggleFlow.path).toBe('/api/v1/automation/:name/toggle'); + expect(AutomationApiContracts.listRuns.path).toBe('/api/v1/automation/:name/runs'); + expect(AutomationApiContracts.getRun.path).toBe('/api/v1/automation/:name/runs/:runId'); }); it('should have input and output schemas for all endpoints', () => { diff --git a/packages/spec/src/api/automation-api.zod.ts b/packages/spec/src/api/automation-api.zod.ts index 2c987b97c55..829a72b2950 100644 --- a/packages/spec/src/api/automation-api.zod.ts +++ b/packages/spec/src/api/automation-api.zod.ts @@ -11,19 +11,25 @@ import { ExecutionLogSchema, ExecutionStatus, FlowRunSummarySchema } from '../au * Defines REST CRUD endpoint schemas for managing automation flows, * triggering executions, and querying execution history. * - * Base path: /api/automation + * Base path: /api/v1/automation + * + * The wire paths the platform serves: the dispatcher mounts this door at its + * `prefix` (default `/api/v1`, the one `objectstack serve` uses) plus + * `/automation`. A drift pin in `@objectstack/runtime` + * (`automation-api-contract-mounts.test.ts`) holds every `path` in + * {@link AutomationApiContracts} to that mount table. * * @example Endpoints * ``` - * GET /api/automation — List flows - * GET /api/automation/:name — Get flow - * POST /api/automation — Create flow - * PUT /api/automation/:name — Update flow - * DELETE /api/automation/:name — Delete flow - * POST /api/automation/:name/trigger — Trigger flow execution - * POST /api/automation/:name/toggle — Enable/disable flow - * GET /api/automation/:name/runs — List execution runs - * GET /api/automation/:name/runs/:runId — Get single execution run + * GET /api/v1/automation — List flows + * GET /api/v1/automation/:name — Get flow + * POST /api/v1/automation — Create flow + * PUT /api/v1/automation/:name — Update flow + * DELETE /api/v1/automation/:name — Delete flow + * POST /api/v1/automation/:name/trigger — Trigger flow execution + * POST /api/v1/automation/:name/toggle — Enable/disable flow + * GET /api/v1/automation/:name/runs — List execution runs + * GET /api/v1/automation/:name/runs/:runId — Get single execution run * ``` */ @@ -50,13 +56,13 @@ export const AutomationRunPathParamsSchema = lazySchema(() => AutomationFlowPath export type AutomationRunPathParams = z.input; // ========================================== -// 2. List Flows (GET /api/automation) +// 2. List Flows (GET /api/v1/automation) // ========================================== /** * Query parameters for listing automation flows. * - * @example GET /api/automation?status=active&limit=20 + * @example GET /api/v1/automation?status=active&limit=20 */ export const ListFlowsRequestSchema = lazySchema(() => z.object({ status: z.enum(['draft', 'active', 'obsolete', 'invalid']).optional() @@ -103,7 +109,7 @@ export type ListFlowsResponse = z.input; export type ListFlowsResponseParsed = z.infer; // ========================================== -// 3. Get Flow (GET /api/automation/:name) +// 3. Get Flow (GET /api/v1/automation/:name) // ========================================== /** @@ -123,13 +129,13 @@ export type GetFlowResponse = z.input; export type GetFlowResponseParsed = z.infer; // ========================================== -// 4. Create Flow (POST /api/automation) +// 4. Create Flow (POST /api/v1/automation) // ========================================== /** * Request body for creating a new flow. * - * @example POST /api/automation + * @example POST /api/v1/automation * { name: 'approval_flow', label: 'Approval Flow', type: 'autolaunched', ... } */ export const CreateFlowRequestSchema = lazySchema(() => FlowSchema); @@ -144,7 +150,7 @@ export type CreateFlowRequestParsed = z.infer; * `FlowSchema.parse` output with schema defaults materialized (`version`, * `status`, `runAs`, per-edge `type`/`isDefault`) and `edge.condition` * strings lowered to their `{dialect, source}` envelopes — the same shape - * `GET /api/automation/:name` answers, never an echo of the request bytes. + * `GET /api/v1/automation/:name` answers, never an echo of the request bytes. */ export const CreateFlowResponseSchema = lazySchema(() => BaseResponseSchema.extend({ data: FlowSchema.describe('The created flow, canonicalized — the parsed shape the engine stored, identical to what a subsequent GET answers'), @@ -154,7 +160,7 @@ export type CreateFlowResponse = z.input; export type CreateFlowResponseParsed = z.infer; // ========================================== -// 5. Update Flow (PUT /api/automation/:name) +// 5. Update Flow (PUT /api/v1/automation/:name) // ========================================== /** @@ -166,7 +172,7 @@ export type CreateFlowResponseParsed = z.infer; * `.partial()` here declared a partial-update capability nothing implements. * A real partial-update capability would be its own feature card. * - * @example PUT /api/automation/approval_flow + * @example PUT /api/v1/automation/approval_flow * { name: 'approval_flow', definition: { name: 'approval_flow', label: 'Approval Flow', type: 'autolaunched', nodes: [...], edges: [...] } } */ export const UpdateFlowRequestSchema = lazySchema(() => AutomationFlowPathParamsSchema.extend({ @@ -191,7 +197,7 @@ export type UpdateFlowResponse = z.input; export type UpdateFlowResponseParsed = z.infer; // ========================================== -// 6. Delete Flow (DELETE /api/automation/:name) +// 6. Delete Flow (DELETE /api/v1/automation/:name) // ========================================== /** @@ -214,13 +220,13 @@ export type DeleteFlowResponse = z.input; export type DeleteFlowResponseParsed = z.infer; // ========================================== -// 7. Trigger Flow (POST /api/automation/:name/trigger) +// 7. Trigger Flow (POST /api/v1/automation/:name/trigger) // ========================================== /** * Request body for triggering a flow execution. * - * @example POST /api/automation/approval_flow/trigger + * @example POST /api/v1/automation/approval_flow/trigger * { record: { id: 'rec-1' }, object: 'account', event: 'on_create' } */ export const TriggerFlowRequestSchema = lazySchema(() => AutomationFlowPathParamsSchema.extend({ @@ -398,14 +404,14 @@ export type TriggerFlowResponse = z.input; export type TriggerFlowResponseParsed = z.infer; // ========================================== -// 7b. Resume failure details (POST /api/automation/:name/runs/:runId/resume, 400 FLOW_FAILED) +// 7b. Resume failure details (POST /api/v1/automation/:name/runs/:runId/resume, 400 FLOW_FAILED) // ========================================== /** * The machine-readable half of a resume failure, as it reaches the caller * (#15221; the #16472 family ruling, maintainer 2026-09-07, option A). * - * `POST /api/automation/:name/runs/:runId/resume` answers a run that consumed + * `POST /api/v1/automation/:name/runs/:runId/resume` answers a run that consumed * its pause and then failed with `400 FLOW_FAILED` (#8684), and the * `error.details` of that answer carried the run's two artefacts only — the * author's `errorMessage` and the per-node `summary`. The engine's own verdict @@ -477,13 +483,13 @@ export type ResumeFailureDetails = z.input; export type ResumeFailureDetailsParsed = z.infer; // ========================================== -// 8. Toggle Flow (POST /api/automation/:name/toggle) +// 8. Toggle Flow (POST /api/v1/automation/:name/toggle) // ========================================== /** * Request body for enabling/disabling a flow. * - * @example POST /api/automation/approval_flow/toggle + * @example POST /api/v1/automation/approval_flow/toggle * { enabled: true } */ export const ToggleFlowRequestSchema = lazySchema(() => AutomationFlowPathParamsSchema.extend({ @@ -505,7 +511,7 @@ export type ToggleFlowResponse = z.input; export type ToggleFlowResponseParsed = z.infer; // ========================================== -// 9. List Runs (GET /api/automation/:name/runs) +// 9. List Runs (GET /api/v1/automation/:name/runs) // ========================================== /** @@ -519,7 +525,7 @@ export type ToggleFlowResponseParsed = z.infer; * at the authoring site) and raises this text at parse time. */ const RUNS_LIST_CURSOR_REMOVED = - '`cursor` was removed from GET /api/automation/:name/runs in @objectstack/spec 17.5.0 ' + '`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 ' @@ -554,7 +560,7 @@ const RUNS_LIST_CURSOR_REMOVED = * unfiltered set and nothing in the status, headers or body distinguishes that * from a request served as asked. * - * @example GET /api/automation/approval_flow/runs?status=completed&limit=10 + * @example GET /api/v1/automation/approval_flow/runs?status=completed&limit=10 */ export const ListRunsRequestSchema = lazySchema(() => AutomationFlowPathParamsSchema.extend({ // [#7359] The canonical `ExecutionStatus`, not a copy of its members. The @@ -605,7 +611,7 @@ export type ListRunsResponse = z.input; export type ListRunsResponseParsed = z.infer; // ========================================== -// 10. Get Run (GET /api/automation/:name/runs/:runId) +// 10. Get Run (GET /api/v1/automation/:name/runs/:runId) // ========================================== /** @@ -655,55 +661,55 @@ export type AutomationApiErrorCode = z.input; export const AutomationApiContracts = { listFlows: { method: 'GET' as const, - path: '/api/automation', + path: '/api/v1/automation', input: ListFlowsRequestSchema, output: ListFlowsResponseSchema, }, getFlow: { method: 'GET' as const, - path: '/api/automation/:name', + path: '/api/v1/automation/:name', input: GetFlowRequestSchema, output: GetFlowResponseSchema, }, createFlow: { method: 'POST' as const, - path: '/api/automation', + path: '/api/v1/automation', input: CreateFlowRequestSchema, output: CreateFlowResponseSchema, }, updateFlow: { method: 'PUT' as const, - path: '/api/automation/:name', + path: '/api/v1/automation/:name', input: UpdateFlowRequestSchema, output: UpdateFlowResponseSchema, }, deleteFlow: { method: 'DELETE' as const, - path: '/api/automation/:name', + path: '/api/v1/automation/:name', input: DeleteFlowRequestSchema, output: DeleteFlowResponseSchema, }, triggerFlow: { method: 'POST' as const, - path: '/api/automation/:name/trigger', + path: '/api/v1/automation/:name/trigger', input: TriggerFlowRequestSchema, output: TriggerFlowResponseSchema, }, toggleFlow: { method: 'POST' as const, - path: '/api/automation/:name/toggle', + path: '/api/v1/automation/:name/toggle', input: ToggleFlowRequestSchema, output: ToggleFlowResponseSchema, }, listRuns: { method: 'GET' as const, - path: '/api/automation/:name/runs', + path: '/api/v1/automation/:name/runs', input: ListRunsRequestSchema, output: ListRunsResponseSchema, }, getRun: { method: 'GET' as const, - path: '/api/automation/:name/runs/:runId', + path: '/api/v1/automation/:name/runs/:runId', input: GetRunRequestSchema, output: GetRunResponseSchema, }, From 4c216561c6b2cd90511c35d9a058f4f664ad6ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 23:29:59 +0000 Subject: [PATCH 2/3] docs(spec): regenerate the automation API reference page Generated by `pnpm --filter @objectstack/spec gen:docs` after the contract moved to /api/v1/automation. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .../docs/references/api/automation-api.mdx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/content/docs/references/api/automation-api.mdx b/content/docs/references/api/automation-api.mdx index 0b4f9d48140..8864e444ede 100644 --- a/content/docs/references/api/automation-api.mdx +++ b/content/docs/references/api/automation-api.mdx @@ -10,19 +10,25 @@ Automation API Protocol Defines REST CRUD endpoint schemas for managing automation flows, triggering executions, and querying execution history. -Base path: /api/automation +Base path: /api/v1/automation + +The wire paths the platform serves: the dispatcher mounts this door at its +`prefix` (default `/api/v1`, the one `objectstack serve` uses) plus +`/automation`. A drift pin in `@objectstack/runtime` +(`automation-api-contract-mounts.test.ts`) holds every `path` in +`AutomationApiContracts` to that mount table. **Endpoints** ``` -GET /api/automation — List flows -GET /api/automation/:name — Get flow -POST /api/automation — Create flow -PUT /api/automation/:name — Update flow -DELETE /api/automation/:name — Delete flow -POST /api/automation/:name/trigger — Trigger flow execution -POST /api/automation/:name/toggle — Enable/disable flow -GET /api/automation/:name/runs — List execution runs -GET /api/automation/:name/runs/:runId — Get single execution run +GET /api/v1/automation — List flows +GET /api/v1/automation/:name — Get flow +POST /api/v1/automation — Create flow +PUT /api/v1/automation/:name — Update flow +DELETE /api/v1/automation/:name — Delete flow +POST /api/v1/automation/:name/trigger — Trigger flow execution +POST /api/v1/automation/:name/toggle — Enable/disable flow +GET /api/v1/automation/:name/runs — List execution runs +GET /api/v1/automation/:name/runs/:runId — Get single execution run ``` @@ -524,7 +530,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** | `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. | +| **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. | --- From 10b117632855d4ccf9bebd2cdde3f0dfb02c53f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 00:28:00 +0000 Subject: [PATCH 3/3] fix(spec): the cursor-retirement upgrade entry names the served /api/v1 runs path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch round 1 on the AutomationApiContracts path fix: - The ADR-0087 D3 entry automation-runs-cursor-retired named GET /api/automation/:name/runs three times; it now names GET /api/v1/automation/:name/runs, the path the contract publishes and the dispatcher mounts. registry.ts regenerated with gen:migration-registry; gen:upgrade-guide and gen:spec-changes re-run with no byte change (the entry is in step 18). - Two comments (runtime query-param.ts, service-automation run-list-truncation.test.ts) quote the same path in the v1 form. - The changeset declares Clause-② no: no key added, no accept set widened, no export grown. The level stays minor. Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d Co-authored-by: Claude --- .changeset/20034-automation-contract-api-v1-paths.md | 2 +- packages/runtime/src/query-param.ts | 2 +- .../service-automation/src/run-list-truncation.test.ts | 2 +- .../entries/semantic/18.automation-runs-cursor-retired.ts | 6 +++--- packages/spec/src/migrations/registry.ts | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/20034-automation-contract-api-v1-paths.md b/.changeset/20034-automation-contract-api-v1-paths.md index 4d8758fc8c7..148fb5beac0 100644 --- a/.changeset/20034-automation-contract-api-v1-paths.md +++ b/.changeset/20034-automation-contract-api-v1-paths.md @@ -6,7 +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. -Clause-②: yes +Clause-②: no **What moved on the published surface** diff --git a/packages/runtime/src/query-param.ts b/packages/runtime/src/query-param.ts index 3729b56146f..2bf9c06932e 100644 --- a/packages/runtime/src/query-param.ts +++ b/packages/runtime/src/query-param.ts @@ -176,7 +176,7 @@ export function parseIntegerParam( /** * A CLOSED-SET parameter — a filter whose declared values are an enum on the - * wire (`?status=failed` on `GET /api/automation/:name/runs`, whose + * wire (`?status=failed` on `GET /api/v1/automation/:name/runs`, whose * `ListRunsRequestSchema` bounds it to `ExecutionStatus` itself — the enum * rather than a copy of its members, so the bound is whatever that vocabulary * declares rather than a count fixed on the day this line was written. #7359 diff --git a/packages/services/service-automation/src/run-list-truncation.test.ts b/packages/services/service-automation/src/run-list-truncation.test.ts index 6935b68636c..31a237b05c2 100644 --- a/packages/services/service-automation/src/run-list-truncation.test.ts +++ b/packages/services/service-automation/src/run-list-truncation.test.ts @@ -3,7 +3,7 @@ /** * #19543 — `AutomationEngine.listRunsPage` and the truncation boundary. * - * `GET /api/automation/:name/runs` used to answer `{ runs, hasMore: false }` + * `GET /api/v1/automation/:name/runs` used to answer `{ 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, with a `200` and nothing in diff --git a/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts b/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts index cf4445205f9..4a90fec3f84 100644 --- a/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts @@ -8,7 +8,7 @@ export const entry: SemanticMigration = { // code span AND a table cell. surface: 'api.listRuns cursor — the pagination query parameter of ' - + 'GET /api/automation/:name/runs declared by ListRunsRequestSchema, its slot on ' + + 'GET /api/v1/automation/:name/runs declared by ListRunsRequestSchema, its slot on ' + 'IAutomationService.listRuns, and its option on all three @objectstack/client run-list ' + 'surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). ' + 'The limit parameter of the same door is NOT part of this retirement and is unchanged, ' @@ -40,7 +40,7 @@ export const entry: SemanticMigration = { + "the Console's flow-runs page sends it today. Retiring it would have been a " + 'regression, and its `.default(20)` stays with it. ' + 'The same card computes `hasMore`, which is the half a bare retirement would have left ' - + 'lying. `GET /api/automation/:name/runs` shipped a literal `hasMore: false` beside a ' + + 'lying. `GET /api/v1/automation/:name/runs` shipped a literal `hasMore: false` beside a ' + 'list the engine had already truncated with `.slice(0, limit)`, so a caller asking for ' + 'one row of a thousand was handed one row and told that was all of them. The engine ' + 'now reports truncation to the door through a new optional contract member, ' @@ -69,7 +69,7 @@ export const entry: SemanticMigration = { + '#6361 retired the notifications `cursor`: the client dropped the option and recorded ' + 'the removal in its docblock. ADR-0049 / ADR-0087, #19543.', acceptanceCriteria: - 'No caller sends `cursor` to `GET /api/automation/:name/runs`, and that is true of every ' + 'No caller sends `cursor` to `GET /api/v1/automation/:name/runs`, and that is true of every ' + 'channel this repo ships rather than of the schema alone. Writing it on a ' + '`ListRunsRequest` is a `tsc` error (the input type is `never`), and any value reaching a ' + 'parse raises the prescription rather than a generic unrecognized-key issue. The option is ' diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 30cf33582d0..e61253e4de6 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5940,7 +5940,7 @@ const step18: MigrationStep = { // code span AND a table cell. surface: 'api.listRuns cursor — the pagination query parameter of ' - + 'GET /api/automation/:name/runs declared by ListRunsRequestSchema, its slot on ' + + 'GET /api/v1/automation/:name/runs declared by ListRunsRequestSchema, its slot on ' + 'IAutomationService.listRuns, and its option on all three @objectstack/client run-list ' + 'surfaces (automation.runs.list, automation.listRuns, environment().automation.listRuns). ' + 'The limit parameter of the same door is NOT part of this retirement and is unchanged, ' @@ -5972,7 +5972,7 @@ const step18: MigrationStep = { + "the Console's flow-runs page sends it today. Retiring it would have been a " + 'regression, and its `.default(20)` stays with it. ' + 'The same card computes `hasMore`, which is the half a bare retirement would have left ' - + 'lying. `GET /api/automation/:name/runs` shipped a literal `hasMore: false` beside a ' + + 'lying. `GET /api/v1/automation/:name/runs` shipped a literal `hasMore: false` beside a ' + 'list the engine had already truncated with `.slice(0, limit)`, so a caller asking for ' + 'one row of a thousand was handed one row and told that was all of them. The engine ' + 'now reports truncation to the door through a new optional contract member, ' @@ -6001,7 +6001,7 @@ const step18: MigrationStep = { + '#6361 retired the notifications `cursor`: the client dropped the option and recorded ' + 'the removal in its docblock. ADR-0049 / ADR-0087, #19543.', acceptanceCriteria: - 'No caller sends `cursor` to `GET /api/automation/:name/runs`, and that is true of every ' + 'No caller sends `cursor` to `GET /api/v1/automation/:name/runs`, and that is true of every ' + 'channel this repo ships rather than of the schema alone. Writing it on a ' + '`ListRunsRequest` is a `tsc` error (the input type is `never`), and any value reaching a ' + 'parse raises the prescription rather than a generic unrecognized-key issue. The option is '