Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/19365-automation-runs-cursor-hasmore.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
---
'@objectstack/spec': minor
'@objectstack/runtime': minor
Expand Down Expand Up @@ -29,7 +29,7 @@
-> { 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) …'
```

Expand Down
28 changes: 28 additions & 0 deletions .changeset/20034-automation-contract-api-v1-paths.md
Original file line number Diff line number Diff line change
@@ -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-②: no

**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.
28 changes: 17 additions & 11 deletions content/docs/references/api/automation-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

<Callout type="info">
Expand Down Expand Up @@ -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. |


---
Expand Down
114 changes: 114 additions & 0 deletions packages/runtime/src/automation-api-contract-mounts.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
2 changes: 1 addition & 1 deletion packages/runtime/src/query-param.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions packages/spec/src/api/automation-api.zod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading
Loading