diff --git a/.changeset/coven-run-history.md b/.changeset/coven-run-history.md new file mode 100644 index 0000000..04de7e6 --- /dev/null +++ b/.changeset/coven-run-history.md @@ -0,0 +1,11 @@ +--- +"@opencoven/coven-client": minor +--- + +Add `automations.runHistory(automationId, { limit, cursor, occurrenceId })` +over the capability-gated `coven.automations.run.history.v1` producer action: +one automation's runs, newest first by start instant, as an sdk-core `Page` +with a keyset cursor of at most 256 characters. Also add `iterateRunHistory()` +on `iteratePages`. Pages that cross automations or occurrences, break +ordering, or do not echo the cursor are refused, and responses share the +256 KiB history cap. Requires OpenCoven/coven#1160. diff --git a/api-baselines/coven.d.ts b/api-baselines/coven.d.ts index 25a201d..eb1610c 100644 --- a/api-baselines/coven.d.ts +++ b/api-baselines/coven.d.ts @@ -144,6 +144,14 @@ interface CovenAutomationReceiptVerification { /** Pure local verification. Unknown or malformed host values produce fixed, non-secret reasons. */ declare function verifyReceipt(receipt: unknown, trustContext: CovenAutomationReceiptTrustContext): CovenAutomationReceiptVerification; +/** A keyset history page's position, shaped as an sdk-core `PageCursor`. */ +interface CovenAutomationHistoryCursor { + readonly hasMore: boolean; + /** The cursor this page was read from; absent on the first page. */ + readonly current?: string; + readonly next?: string; +} + interface CovenAutomationRunsOptions { /** Newest-first history size, 1–100 (default 20). No pagination cursor is exposed by the producer. */ readonly limit?: number; @@ -202,6 +210,24 @@ interface CovenAutomationRun { interface CovenAutomationRunsResult { readonly runs: readonly CovenAutomationRun[]; } +interface CovenAutomationRunHistoryOptions { + /** 1–100, default 20. */ + readonly limit?: number; + /** An opaque `cursor.next` from an earlier page of the same query. */ + readonly cursor?: string; + /** Restricts the history to one occurrence's runs. */ + readonly occurrenceId?: string; +} +/** + * One page of an automation's runs, newest first by start instant then + * `id`, each with its attempts. Shaped as an sdk-core `Page`. + */ +interface CovenAutomationRunHistoryPage { + readonly automationId: string; + readonly occurrenceId?: string; + readonly data: readonly CovenAutomationRun[]; + readonly cursor: CovenAutomationHistoryCursor; +} /** Domain streams only: the global feed has a separate, non-domain cursor. */ interface CovenAutomationEventStream { @@ -371,12 +397,7 @@ interface CovenAutomationOccurrenceHistoryOptions { interface CovenAutomationOccurrenceHistoryPage { readonly automationId: string; readonly data: readonly CovenAutomationOccurrence[]; - readonly cursor: { - readonly hasMore: boolean; - /** The cursor this page was read from; absent on the first page. */ - readonly current?: string; - readonly next?: string; - }; + readonly cursor: CovenAutomationHistoryCursor; } interface CovenAutomationOccurrenceResult { readonly occurrence: CovenAutomationOccurrenceDetail | null; @@ -474,6 +495,12 @@ type CovenAutomationDefinitionReadRequest = { } | { readonly action: 'coven.automations.occurrence.get.v1'; readonly id: string; +} | { + readonly action: 'coven.automations.run.history.v1'; + readonly automationId: string; + readonly limit: number; + readonly occurrenceId?: string; + readonly cursor?: string; } | { readonly action: 'coven.automations.run.get.v1'; readonly id: string; @@ -545,6 +572,16 @@ declare class CovenAutomationsClient { * `maxPages` or a caller-owned `signal`; `timeoutMs` bounds the whole walk. */ iterateOccurrenceHistory(automationId: string, options: BoundedPageOptions): AsyncGenerator; + /** One page of one automation's runs, optionally for one occurrence, newest first. */ + runHistory(automationId: string, query?: CovenAutomationRunHistoryOptions, options?: OperationOptions): Promise; + /** + * Every run of one automation, optionally for one occurrence, newest first + * across pages. Requires `maxPages` or a caller-owned `signal`; `timeoutMs` + * bounds the whole walk. + */ + iterateRunHistory(automationId: string, options: BoundedPageOptions, query?: { + readonly occurrenceId?: string; + }): AsyncGenerator; /** One run and its attempts by run id, read from one producer snapshot. */ getRun(runId: string, options?: OperationOptions): Promise; getOccurrence(id: string, options?: OperationOptions): Promise; @@ -1057,4 +1094,4 @@ type CovenAutomationEventReduction = { /** Bounded supplied-batch reference projection; no persistence, transport or execution authority. */ declare function reduceAutomationEvents(events: unknown): CovenAutomationEventReduction; -export { COVEN_DAEMON_PROTOCOL, COVEN_SESSION_POLICY_CONTRACT, COVEN_SESSION_POLICY_PROFILE, type CovenAutomationAttempt, type CovenAutomationCapabilities, type CovenAutomationCapabilityProfile, type CovenAutomationDefinition, type CovenAutomationDefinitionDigestResult, type CovenAutomationDefinitionDocument, type CovenAutomationDefinitionList, type CovenAutomationDefinitionReadRequest, type CovenAutomationEvent, type CovenAutomationEventIntegrity, type CovenAutomationEventPage, type CovenAutomationEventReduction, type CovenAutomationEventStream, type CovenAutomationEventsOptions, type CovenAutomationEventsRequest, type CovenAutomationHealth, type CovenAutomationHealthResult, type CovenAutomationListOptions, type CovenAutomationOccurrence, type CovenAutomationOccurrenceDetail, type CovenAutomationOccurrenceHistoryOptions, type CovenAutomationOccurrenceHistoryPage, type CovenAutomationOccurrenceResult, type CovenAutomationOccurrenceRun, type CovenAutomationOccurrenceView, type CovenAutomationOccurrencesOptions, type CovenAutomationOccurrencesResult, type CovenAutomationProjectionJson, type CovenAutomationReceipt, type CovenAutomationReceiptDigest, type CovenAutomationReceiptReadVerification, type CovenAutomationReceiptResult, type CovenAutomationReceiptTrustContext, type CovenAutomationReceiptVerification, type CovenAutomationReceiptVerificationCheck, type CovenAutomationReceiptVerificationReason, type CovenAutomationRoutine, type CovenAutomationRun, type CovenAutomationRunCancellation, type CovenAutomationRunResult, type CovenAutomationRunsOptions, type CovenAutomationRunsResult, type CovenAutomationVariant, CovenAutomationsClient, type CovenAutomationsClientOptions, type CovenAutomationsTransport, type CovenAutomationsUnixTransportOptions, type CovenAutomationsWindowsTransportOptions, CovenClient, CovenClientError, type CovenClientOptions, type CovenConnectedSocket, type CovenDaemonFailure, CovenDaemonResponseError, type CovenDiscoveredClientOptions, type CovenDiscoveredEndpoint, type CovenDiscoveredUnixClientOptions, type CovenDiscoveredUnixTransportOptions, type CovenDiscoveredWindowsClientOptions, type CovenDiscoveredWindowsTransportOptions, type CovenDiscoveryDependencies, type CovenDiscoveryFileIdentity, type CovenDiscoverySource, type CovenEndpointFreshness, type CovenEndpointOwner, type CovenExecFile, type CovenExecFileError, type CovenExecFileOptions, type CovenExecutableResolver, type CovenHealth, type CovenHealthResponse, type CovenHealthTransportLimits, type CovenIpcDiagnostics, CovenIpcError, type CovenIpcErrorCode, type CovenMetadataFileHandle, type CovenRestrictedLaunchRequest, CovenSessionPolicyClient, type CovenSessionPolicyClientOptions, type CovenSessionPolicyDelivery, type CovenSessionPolicyDiscovery, CovenSessionPolicyError, type CovenSessionPolicyErrorCode, type CovenSessionPolicyRefusal, type CovenSessionPolicyTransport, type CovenSessionPolicyTransportRequest, type CovenSessionPolicyTransportResponse, type CovenSessionPolicyUnixTransportOptions, type CovenSocket, type CovenSocketConnector, type CovenTransport, type CovenTransportSecurityProvider, type CovenUnixFileIdentity, type CovenUnixPeerIdentity, type CovenUnixPeerIdentityAdapter, type CovenUnixTransportDependencies, type CovenUnixTransportOptions, type CovenUnixTransportSecurityProvider, type CovenWindowsFileTrustValidator, type CovenWindowsPipeIdentity, type CovenWindowsPipeOwnershipAdapter, type CovenWindowsTransportDependencies, type CovenWindowsTransportOptions, type CovenWindowsTransportSecurityProvider, type DiscoverCovenEndpointOptions, computeDefinitionDigest, createCovenAutomationsClient, createCovenAutomationsUnixTransport, createCovenAutomationsWindowsTransport, createCovenClient, createCovenSessionPolicyClient, createCovenSessionPolicyUnixTransport, createCovenUnixTransport, createCovenWindowsTransport, createDiscoveredCovenClient, discoverCovenEndpoint, isCovenClientError, isCovenDaemonResponseError, isCovenIpcError, isCovenSessionPolicyError, normalizeCovenError, reduceAutomationEvents, verifyEventIntegrity, verifyReceipt }; +export { COVEN_DAEMON_PROTOCOL, COVEN_SESSION_POLICY_CONTRACT, COVEN_SESSION_POLICY_PROFILE, type CovenAutomationAttempt, type CovenAutomationCapabilities, type CovenAutomationCapabilityProfile, type CovenAutomationDefinition, type CovenAutomationDefinitionDigestResult, type CovenAutomationDefinitionDocument, type CovenAutomationDefinitionList, type CovenAutomationDefinitionReadRequest, type CovenAutomationEvent, type CovenAutomationEventIntegrity, type CovenAutomationEventPage, type CovenAutomationEventReduction, type CovenAutomationEventStream, type CovenAutomationEventsOptions, type CovenAutomationEventsRequest, type CovenAutomationHealth, type CovenAutomationHealthResult, type CovenAutomationHistoryCursor, type CovenAutomationListOptions, type CovenAutomationOccurrence, type CovenAutomationOccurrenceDetail, type CovenAutomationOccurrenceHistoryOptions, type CovenAutomationOccurrenceHistoryPage, type CovenAutomationOccurrenceResult, type CovenAutomationOccurrenceRun, type CovenAutomationOccurrenceView, type CovenAutomationOccurrencesOptions, type CovenAutomationOccurrencesResult, type CovenAutomationProjectionJson, type CovenAutomationReceipt, type CovenAutomationReceiptDigest, type CovenAutomationReceiptReadVerification, type CovenAutomationReceiptResult, type CovenAutomationReceiptTrustContext, type CovenAutomationReceiptVerification, type CovenAutomationReceiptVerificationCheck, type CovenAutomationReceiptVerificationReason, type CovenAutomationRoutine, type CovenAutomationRun, type CovenAutomationRunCancellation, type CovenAutomationRunHistoryOptions, type CovenAutomationRunHistoryPage, type CovenAutomationRunResult, type CovenAutomationRunsOptions, type CovenAutomationRunsResult, type CovenAutomationVariant, CovenAutomationsClient, type CovenAutomationsClientOptions, type CovenAutomationsTransport, type CovenAutomationsUnixTransportOptions, type CovenAutomationsWindowsTransportOptions, CovenClient, CovenClientError, type CovenClientOptions, type CovenConnectedSocket, type CovenDaemonFailure, CovenDaemonResponseError, type CovenDiscoveredClientOptions, type CovenDiscoveredEndpoint, type CovenDiscoveredUnixClientOptions, type CovenDiscoveredUnixTransportOptions, type CovenDiscoveredWindowsClientOptions, type CovenDiscoveredWindowsTransportOptions, type CovenDiscoveryDependencies, type CovenDiscoveryFileIdentity, type CovenDiscoverySource, type CovenEndpointFreshness, type CovenEndpointOwner, type CovenExecFile, type CovenExecFileError, type CovenExecFileOptions, type CovenExecutableResolver, type CovenHealth, type CovenHealthResponse, type CovenHealthTransportLimits, type CovenIpcDiagnostics, CovenIpcError, type CovenIpcErrorCode, type CovenMetadataFileHandle, type CovenRestrictedLaunchRequest, CovenSessionPolicyClient, type CovenSessionPolicyClientOptions, type CovenSessionPolicyDelivery, type CovenSessionPolicyDiscovery, CovenSessionPolicyError, type CovenSessionPolicyErrorCode, type CovenSessionPolicyRefusal, type CovenSessionPolicyTransport, type CovenSessionPolicyTransportRequest, type CovenSessionPolicyTransportResponse, type CovenSessionPolicyUnixTransportOptions, type CovenSocket, type CovenSocketConnector, type CovenTransport, type CovenTransportSecurityProvider, type CovenUnixFileIdentity, type CovenUnixPeerIdentity, type CovenUnixPeerIdentityAdapter, type CovenUnixTransportDependencies, type CovenUnixTransportOptions, type CovenUnixTransportSecurityProvider, type CovenWindowsFileTrustValidator, type CovenWindowsPipeIdentity, type CovenWindowsPipeOwnershipAdapter, type CovenWindowsTransportDependencies, type CovenWindowsTransportOptions, type CovenWindowsTransportSecurityProvider, type DiscoverCovenEndpointOptions, computeDefinitionDigest, createCovenAutomationsClient, createCovenAutomationsUnixTransport, createCovenAutomationsWindowsTransport, createCovenClient, createCovenSessionPolicyClient, createCovenSessionPolicyUnixTransport, createCovenUnixTransport, createCovenWindowsTransport, createDiscoveredCovenClient, discoverCovenEndpoint, isCovenClientError, isCovenDaemonResponseError, isCovenIpcError, isCovenSessionPolicyError, normalizeCovenError, reduceAutomationEvents, verifyEventIntegrity, verifyReceipt }; diff --git a/packages/coven/README.md b/packages/coven/README.md index 7bb8261..afb103e 100644 --- a/packages/coven/README.md +++ b/packages/coven/README.md @@ -86,7 +86,7 @@ provider, and platform dependencies. Construction sends no health, capability, or action request. Each later request authenticates its own connection. The `unix`/`windows` health response-limit options still apply only to health; Automations keeps its fixed 16 KiB body limit, except for the event (1 MiB) and -occurrence-history (256 KiB) reads described below. +occurrence- and run-history (256 KiB) reads described below. `client.automations` is `CovenAutomationsClient | undefined`. `client.requireAutomations()` returns that same namespace or throws @@ -135,6 +135,7 @@ does not poll or activate Automations. | `list()`, `get()`, `health()` | Allowlisted reads | Same actions and decoders | | `runs()`, `occurrences()`, `getOccurrence()`, `getRun()` | Bounded diagnostic reads | Same actions and decoders | | `occurrenceHistory()`, `iterateOccurrenceHistory()` | Keyset-paged diagnostic reads | Same actions and decoders | +| `runHistory()`, `iterateRunHistory()` | Keyset-paged diagnostic reads | Same actions and decoders | | `getReceipt()` | Public/operational receipt result | Same result and privacy checks | | `events()`, `subscribe()` | Bounded domain event pages | Same pages and cancellation | | Normal/discovered client and `sdk.coven` | Explicit opt-in | Explicit opt-in | @@ -209,7 +210,7 @@ missing action names fail with `capability_unsupported` without posting an actio Custom capability-only transports remain compatible; reads without the optional `readDefinitions` hook fail with `unsupported_operation`. -The built-in Unix and Windows transports send only these nine allowlisted JSON actions to +The built-in Unix and Windows transports send only these ten allowlisted JSON actions to `POST /api/v1/actions`. It authenticates each connection, including the separate capability request, under one client deadline/cancellation scope. It cannot send mutations through this hook. IDs are trimmed as the producer does; the SDK @@ -254,7 +255,7 @@ Failure/exhaustion counters are nonnegative safe integers and `maxAttempts` is Missing routines produce sanitized `action_rejected`, not an invented null result. Health is store-derived diagnostic data, not execution or receipt authority. Custom transports use the existing optional `readDefinitions` hook, whose -historical name now covers all nine explicitly allowlisted read actions. +historical name now covers all ten explicitly allowlisted read actions. Health source authority was independently read from Coven [`b3b2d043a4ee586ccbf25ef6aad21db8a1171a54`](https://github.com/OpenCoven/coven/tree/b3b2d043a4ee586ccbf25ef6aad21db8a1171a54): @@ -635,6 +636,39 @@ per-call timeout. Both need a Coven producer that advertises the action ([coven#1157](https://github.com/OpenCoven/coven/issues/1157)); an older producer yields `capability_unsupported`. +### Run history + +```ts +const page = await automations.runHistory('morning', { limit: 20, occurrenceId: 'occurrence-1' }); +for await (const run of automations.iterateRunHistory('morning', { limit: 50, maxPages: 10 })) { + console.log(run.startedAt, run.status, run.attempts.length); +} +``` + +`runHistory(automationId, { limit?, cursor?, occurrenceId? }, operationOptions?)` +calls `coven.automations.run.history.v1`. It returns an sdk-core `Page` of +`{ automationId, occurrenceId?, data, cursor }`: one automation's runs, newest +first by start instant then `id`, each with its attempts and any cancellation, +read in one producer snapshot. Each run has the same projection and validation +as `runs()`. `occurrenceId` restricts the page to one occurrence's runs. Its +cursors are bounded at **256 characters**, tighter than occurrence history's 512. + +The SDK refuses a page that: + +- names another automation; +- echoes an `occurrenceId` that was not requested, or omits one that was; +- holds a run of another occurrence; +- breaks newest-first order by instant; +- misreports `hasMore` or `next`; +- does not echo the requested cursor. + +Responses share the 256 KiB history cap, so a page of large `logJson` values +fails closed and a smaller `limit` reads it. + +`iterateRunHistory(automationId, boundedPageOptions, { occurrenceId? })` walks +pages with sdk-core `iteratePages`. Both need a Coven producer that advertises +the action ([coven#1160](https://github.com/OpenCoven/coven/pull/1160)). + `getOccurrence(id, operationOptions?)` calls `coven.automations.occurrence.get.v1` and returns `{ occurrence: null } for absence, or a diagnostic detail record. The detail contains up to **20 oldest @@ -733,7 +767,7 @@ events on reconnect. Event responses are capped at 1 MiB and 100 wire events, with the existing 16-level JSON depth limit. Oversized or malformed pages fail closed without yielding partial events. All other Automations and policy response limits -remain 16 KiB, except occurrence history (256 KiB). A producer page larger than the event byte cap cannot be +remain 16 KiB, except occurrence and run history (256 KiB). A producer page larger than the event byte cap cannot be retried with a smaller subscription limit because the producer forbids that field. The SDK does not silently truncate it. diff --git a/packages/coven/src/automations-definitions.ts b/packages/coven/src/automations-definitions.ts index b9931bf..d224fc5 100644 --- a/packages/coven/src/automations-definitions.ts +++ b/packages/coven/src/automations-definitions.ts @@ -5,9 +5,13 @@ import { AUTOMATION_EVENTS_MAX_BYTES, decodeEvents, eventsRequestBytes, type CovenAutomationEventPage, type CovenAutomationEventsRequest, } from './automations-events.js'; import { decodeReceiptRead, receiptId, type CovenAutomationReceiptResult } from './automations-receipts.js'; -import { runsPayload, type CovenAutomationRunsResult } from './automations-runs.js'; +import { AUTOMATION_HISTORY_MAX_BYTES, historyCursor } from './automations-history.js'; import { - AUTOMATION_HISTORY_MAX_BYTES, historyCursor, occurrenceHistoryPayload, occurrencePayload, occurrencesPayload, + RUN_HISTORY_CURSOR_MAX_LENGTH, runHistoryPayload, runsPayload, + type CovenAutomationRunHistoryPage, type CovenAutomationRunsResult, +} from './automations-runs.js'; +import { + occurrenceHistoryPayload, occurrencePayload, occurrencesPayload, occurrenceView, runPayload, type CovenAutomationOccurrenceHistoryPage, type CovenAutomationOccurrenceResult, type CovenAutomationOccurrencesResult, type CovenAutomationOccurrenceView, type CovenAutomationRunResult, @@ -99,6 +103,13 @@ export type CovenAutomationDefinitionReadRequest = readonly cursor?: string; } | { readonly action: 'coven.automations.occurrence.get.v1'; readonly id: string } + | { + readonly action: 'coven.automations.run.history.v1'; + readonly automationId: string; + readonly limit: number; + readonly occurrenceId?: string; + readonly cursor?: string; + } | { readonly action: 'coven.automations.run.get.v1'; readonly id: string } | { readonly action: 'coven.automations.receipt.get.v1'; readonly id: string } | CovenAutomationEventsRequest; @@ -167,6 +178,24 @@ export function definitionReadBytes(request: CovenAutomationDefinitionReadReques action, automationId: automationId.trim(), limit, ...(paged ? { cursor } : {}), })); } + if (action === 'coven.automations.run.history.v1') { + const identifier = (value: unknown): value is string => typeof value === 'string' && + value.trim().length > 0 && Buffer.byteLength(value) <= 4_096 && value.isWellFormed(); + const automationId = own('automationId'); + const limit = own('limit'); + const filtered = Object.hasOwn(descriptors, 'occurrenceId'); + const occurrenceId = own('occurrenceId'); + const paged = Object.hasOwn(descriptors, 'cursor'); + const cursor = own('cursor'); + if (keys.length !== 3 + (filtered ? 1 : 0) + (paged ? 1 : 0) || !identifier(automationId) || + !integer(limit, 1, 100) || (filtered && !identifier(occurrenceId)) || + (paged && !historyCursor(cursor, RUN_HISTORY_CURSOR_MAX_LENGTH))) return invalid(); + return Buffer.from(JSON.stringify({ + action, automationId: automationId.trim(), limit, + ...(filtered ? { occurrenceId: (occurrenceId as string).trim() } : {}), + ...(paged ? { cursor } : {}), + })); + } if (keys.length !== (action === 'coven.automations.runs' ? 3 : 2)) return invalid(); if (action === 'coven.automations.definition.list.v1' && typeof own('includeTombstoned') === 'boolean') { return Buffer.from(JSON.stringify({ action, includeTombstoned: own('includeTombstoned') })); @@ -192,12 +221,13 @@ export function decodeDefinitionRead( operation: string, ): CovenAutomationDefinitionList | CovenAutomationDefinition | CovenAutomationHealthResult | CovenAutomationRunsResult | CovenAutomationOccurrencesResult | CovenAutomationOccurrenceHistoryPage | CovenAutomationOccurrenceResult | - CovenAutomationRunResult | CovenAutomationReceiptResult | CovenAutomationEventPage { + CovenAutomationRunResult | CovenAutomationRunHistoryPage | CovenAutomationReceiptResult | CovenAutomationEventPage { const invalid = (): never => definitionReadFailure('invalid_response', operation); let value: unknown; try { value = parsePolicyJson(bytes, request.action === 'coven.automations.events.subscribe.v1' ? AUTOMATION_EVENTS_MAX_BYTES - : request.action === 'coven.automations.occurrence.history.v1' ? AUTOMATION_HISTORY_MAX_BYTES : 16_384); + : request.action === 'coven.automations.occurrence.history.v1' || request.action === 'coven.automations.run.history.v1' + ? AUTOMATION_HISTORY_MAX_BYTES : 16_384); } catch { return invalid(); } @@ -222,6 +252,10 @@ export function decodeDefinitionRead( if (request.action === 'coven.automations.occurrence.history.v1') { return occurrenceHistoryPayload(payload, request.automationId.trim(), request.limit, request.cursor) ?? invalid(); } + if (request.action === 'coven.automations.run.history.v1') { + return runHistoryPayload(payload, request.automationId.trim(), request.occurrenceId?.trim(), request.limit, + request.cursor) ?? invalid(); + } if (request.action === 'coven.automations.occurrence.get.v1') { return occurrencePayload(payload, request.id.trim()) ?? invalid(); } diff --git a/packages/coven/src/automations-history.ts b/packages/coven/src/automations-history.ts new file mode 100644 index 0000000..0d3c7e3 --- /dev/null +++ b/packages/coven/src/automations-history.ts @@ -0,0 +1,74 @@ +import { normalizePageOptions } from '@opencoven/sdk-core'; + +import { object } from './automations-read-validation.js'; + +/** Largest history response accepted: room for 100 records, far above the 16 KiB read cap. */ +export const AUTOMATION_HISTORY_MAX_BYTES = 262_144; + +/** A keyset history page's position, shaped as an sdk-core `PageCursor`. */ +export interface CovenAutomationHistoryCursor { + readonly hasMore: boolean; + /** The cursor this page was read from; absent on the first page. */ + readonly current?: string; + readonly next?: string; +} + +/** + * A cursor sdk-core would accept (canonical unpadded base64url, at most 512 + * characters) that also fits the action's own `maxLength`. + */ +export function historyCursor(value: unknown, maxLength = 512): value is string { + if (typeof value !== 'string' || value.length > maxLength) return false; + try { + normalizePageOptions({ cursor: value }); + return true; + } catch { + return false; + } +} + +/** + * The producer's history sort key: the timestamp with its fraction padded to + * nine digits, so millisecond and nanosecond rows order by instant. Mirrors + * the SQL expression character for character. + */ +function historySortKey(timestamp: string): Buffer { + const characters = Array.from(timestamp); + const fraction = characters.slice(20).join('').replace(/Z+$/u, ''); + return Buffer.from(`${characters.slice(0, 19).join('')}.${Array.from(`${fraction}000000000`).slice(0, 9).join('')}Z`); +} + +/** Strictly newest first by instant, then id, compared as SQLite does: by bytes. */ +export function newestFirst(rows: readonly T[], instant: (row: T) => string, id: (row: T) => string): boolean { + return rows.every((row, index) => { + if (index === 0) return true; + const previous = rows[index - 1]!; + const order = Buffer.compare(historySortKey(instant(previous)), historySortKey(instant(row))); + return order > 0 || (order === 0 && Buffer.compare(Buffer.from(id(previous)), Buffer.from(id(row))) > 0); + }); +} + +/** + * Validates a page's `cursor` against the request: `current` echoes the + * requested cursor, `next` is a fresh cursor exactly when `hasMore`, and a + * page claiming more is full. + */ +export function historyPageCursor( + position: unknown, + cursor: string | undefined, + rows: number, + limit: number, + maxLength = 512, +): CovenAutomationHistoryCursor | undefined { + if (!object(position) || typeof position.hasMore !== 'boolean' || + Reflect.ownKeys(position).some((key) => key !== 'hasMore' && key !== 'current' && key !== 'next') || + (cursor === undefined ? Object.hasOwn(position, 'current') : position.current !== cursor)) return undefined; + if (position.hasMore + ? !historyCursor(position.next, maxLength) || position.next === cursor || rows !== limit + : Object.hasOwn(position, 'next')) return undefined; + return { + hasMore: position.hasMore, + ...(cursor === undefined ? {} : { current: cursor }), + ...(position.hasMore ? { next: position.next as string } : {}), + }; +} diff --git a/packages/coven/src/automations-occurrences.ts b/packages/coven/src/automations-occurrences.ts index 7d64844..f16d07c 100644 --- a/packages/coven/src/automations-occurrences.ts +++ b/packages/coven/src/automations-occurrences.ts @@ -1,5 +1,4 @@ -import { normalizePageOptions } from '@opencoven/sdk-core'; - +import { historyPageCursor, newestFirst, type CovenAutomationHistoryCursor } from './automations-history.js'; import { integer, object } from './automations-read-validation.js'; import { runsPayload, type CovenAutomationRun } from './automations-runs.js'; @@ -53,9 +52,6 @@ export interface CovenAutomationOccurrencesResult { readonly occurrences: readonly CovenAutomationOccurrence[]; } -/** Largest history response accepted: room for 100 records, far above the 16 KiB read cap. */ -export const AUTOMATION_HISTORY_MAX_BYTES = 262_144; - export interface CovenAutomationOccurrenceHistoryOptions { /** 1–100, default 20. */ readonly limit?: number; @@ -70,12 +66,7 @@ export interface CovenAutomationOccurrenceHistoryOptions { export interface CovenAutomationOccurrenceHistoryPage { readonly automationId: string; readonly data: readonly CovenAutomationOccurrence[]; - readonly cursor: { - readonly hasMore: boolean; - /** The cursor this page was read from; absent on the first page. */ - readonly current?: string; - readonly next?: string; - }; + readonly cursor: CovenAutomationHistoryCursor; } export interface CovenAutomationOccurrenceResult { @@ -113,34 +104,6 @@ export function occurrencesPayload( return { occurrences: value.occurrences }; } -/** A cursor sdk-core would accept: canonical unpadded base64url, at most 512 characters. */ -export function historyCursor(value: unknown): value is string { - if (typeof value !== 'string') return false; - try { - normalizePageOptions({ cursor: value }); - return true; - } catch { - return false; - } -} - -/** - * The producer's history sort key: the timestamp with its fraction padded to - * nine digits, so millisecond scheduled rows and nanosecond manual rows order - * by instant. Mirrors the SQL expression character for character. - */ -function historySortKey(scheduledFor: string): Buffer { - const characters = Array.from(scheduledFor); - const fraction = characters.slice(20).join('').replace(/Z+$/u, ''); - return Buffer.from(`${characters.slice(0, 19).join('')}.${Array.from(`${fraction}000000000`).slice(0, 9).join('')}Z`); -} - -function newerThan(left: CovenAutomationOccurrence, right: CovenAutomationOccurrence): boolean { - // The producer compares keys and ids as SQLite does, by bytes, not UTF-16 code units. - const order = Buffer.compare(historySortKey(left.scheduledFor), historySortKey(right.scheduledFor)); - return order > 0 || (order === 0 && Buffer.compare(Buffer.from(left.id), Buffer.from(right.id)) > 0); -} - export function occurrenceHistoryPayload( value: Record, automationId: string, @@ -148,24 +111,11 @@ export function occurrenceHistoryPayload( cursor: string | undefined, ): CovenAutomationOccurrenceHistoryPage | undefined { const page = value.automationId === automationId ? occurrencesPayload(value, limit, automationId) : undefined; - const position = value.cursor; - if (page === undefined || !object(position) || typeof position.hasMore !== 'boolean' || - Reflect.ownKeys(position).some((key) => key !== 'hasMore' && key !== 'current' && key !== 'next') || - (cursor === undefined ? Object.hasOwn(position, 'current') : position.current !== cursor)) return undefined; + if (page === undefined) return undefined; const rows = page.occurrences; - if (position.hasMore - ? !historyCursor(position.next) || position.next === cursor || rows.length !== limit - : Object.hasOwn(position, 'next')) return undefined; - if (rows.some((row, index) => index > 0 && !newerThan(rows[index - 1]!, row))) return undefined; - return { - automationId, - data: rows, - cursor: { - hasMore: position.hasMore, - ...(cursor === undefined ? {} : { current: cursor }), - ...(position.hasMore ? { next: position.next as string } : {}), - }, - }; + const position = historyPageCursor(value.cursor, cursor, rows.length, limit); + if (position === undefined || !newestFirst(rows, (row) => row.scheduledFor, (row) => row.id)) return undefined; + return { automationId, data: rows, cursor: position }; } function occurrenceRunFields(record: Record): boolean { diff --git a/packages/coven/src/automations-runs.ts b/packages/coven/src/automations-runs.ts index 7b0fd4f..2ab41fb 100644 --- a/packages/coven/src/automations-runs.ts +++ b/packages/coven/src/automations-runs.ts @@ -1,3 +1,4 @@ +import { historyPageCursor, newestFirst, type CovenAutomationHistoryCursor } from './automations-history.js'; import { integer, object } from './automations-read-validation.js'; export interface CovenAutomationRunsOptions { @@ -128,3 +129,47 @@ export function runsPayload(value: Record, id: string, limit: n } return { runs: value.runs as CovenAutomationRun[] }; } + +/** `run.history.v1` cursors are bounded tighter than sdk-core's 512 characters. */ +export const RUN_HISTORY_CURSOR_MAX_LENGTH = 256; + +export interface CovenAutomationRunHistoryOptions { + /** 1–100, default 20. */ + readonly limit?: number; + /** An opaque `cursor.next` from an earlier page of the same query. */ + readonly cursor?: string; + /** Restricts the history to one occurrence's runs. */ + readonly occurrenceId?: string; +} + +/** + * One page of an automation's runs, newest first by start instant then + * `id`, each with its attempts. Shaped as an sdk-core `Page`. + */ +export interface CovenAutomationRunHistoryPage { + readonly automationId: string; + readonly occurrenceId?: string; + readonly data: readonly CovenAutomationRun[]; + readonly cursor: CovenAutomationHistoryCursor; +} + +export function runHistoryPayload( + value: Record, + automationId: string, + occurrenceId: string | undefined, + limit: number, + cursor: string | undefined, +): CovenAutomationRunHistoryPage | undefined { + if (value.automationId !== automationId || + (occurrenceId === undefined ? Object.hasOwn(value, 'occurrenceId') : value.occurrenceId !== occurrenceId)) { + return undefined; + } + const page = runsPayload(value, automationId, limit); + if (page === undefined) return undefined; + const rows = page.runs; + const position = historyPageCursor(value.cursor, cursor, rows.length, limit, RUN_HISTORY_CURSOR_MAX_LENGTH); + if (position === undefined || + (occurrenceId !== undefined && !rows.every((run) => run.occurrenceId === occurrenceId)) || + !newestFirst(rows, (run) => run.startedAt, (run) => run.id)) return undefined; + return { automationId, ...(occurrenceId === undefined ? {} : { occurrenceId }), data: rows, cursor: position }; +} diff --git a/packages/coven/src/automations-socket.ts b/packages/coven/src/automations-socket.ts index 24a2f78..59fa40d 100644 --- a/packages/coven/src/automations-socket.ts +++ b/packages/coven/src/automations-socket.ts @@ -3,7 +3,7 @@ import { runOperation, type OperationContext } from '@opencoven/sdk-core'; import type { CovenAutomationsTransport } from './automations.js'; import { definitionReadBytes } from './automations-definitions.js'; import { AUTOMATION_EVENTS_MAX_BYTES } from './automations-events.js'; -import { AUTOMATION_HISTORY_MAX_BYTES } from './automations-occurrences.js'; +import { AUTOMATION_HISTORY_MAX_BYTES } from './automations-history.js'; import { CovenClientError, normalizeCovenError } from './client-errors.js'; import { requestCovenPolicyOverSocket, @@ -51,7 +51,8 @@ export function createCovenAutomationsSocketTransport( ), body, ]), context, 'automations.read', action === 'coven.automations.events.subscribe.v1' ? AUTOMATION_EVENTS_MAX_BYTES - : action === 'coven.automations.occurrence.history.v1' ? AUTOMATION_HISTORY_MAX_BYTES : 16_384); + : action === 'coven.automations.occurrence.history.v1' || action === 'coven.automations.run.history.v1' + ? AUTOMATION_HISTORY_MAX_BYTES : 16_384); }, }; } diff --git a/packages/coven/src/automations.ts b/packages/coven/src/automations.ts index 0be8eed..e43394c 100644 --- a/packages/coven/src/automations.ts +++ b/packages/coven/src/automations.ts @@ -15,12 +15,16 @@ import { verifyReceipt, type CovenAutomationReceiptTrustContext, type CovenAutomationReceiptVerification, } from './automations-receipt-verification.js'; -import type { CovenAutomationRunsOptions, CovenAutomationRunsResult } from './automations-runs.js'; +import { historyCursor } from './automations-history.js'; +import { + RUN_HISTORY_CURSOR_MAX_LENGTH, + type CovenAutomationRun, type CovenAutomationRunHistoryOptions, type CovenAutomationRunHistoryPage, + type CovenAutomationRunsOptions, type CovenAutomationRunsResult, +} from './automations-runs.js'; import { eventsOptions, eventsRequest, subscribeEvents, type CovenAutomationEventPage, type CovenAutomationEventsOptions, } from './automations-events.js'; import { - historyCursor, occurrenceView, type CovenAutomationOccurrence, type CovenAutomationOccurrenceHistoryOptions, type CovenAutomationOccurrenceHistoryPage, @@ -245,6 +249,53 @@ export class CovenAutomationsClient { ); } + /** One page of one automation's runs, optionally for one occurrence, newest first. */ + async runHistory( + automationId: string, + query: CovenAutomationRunHistoryOptions = {}, + options: OperationOptions = {}, + ): Promise { + if (!object(query) || (query.limit !== undefined && !integer(query.limit, 1, 100)) || + Reflect.ownKeys(query).some((key) => key !== 'limit' && key !== 'cursor' && key !== 'occurrenceId') || + (Object.hasOwn(query, 'cursor') && !historyCursor(query.cursor, RUN_HISTORY_CURSOR_MAX_LENGTH)) || + (Object.hasOwn(query, 'occurrenceId') && + (typeof query.occurrenceId !== 'string' || query.occurrenceId.trim().length === 0))) { + return definitionReadFailure('invalid_options', 'automations.runHistory'); + } + return await this.#read({ + action: 'coven.automations.run.history.v1', automationId, limit: query.limit ?? 20, + ...(Object.hasOwn(query, 'occurrenceId') ? { occurrenceId: query.occurrenceId as string } : {}), + ...(Object.hasOwn(query, 'cursor') ? { cursor: query.cursor as string } : {}), + }, options) as CovenAutomationRunHistoryPage; + } + + /** + * Every run of one automation, optionally for one occurrence, newest first + * across pages. Requires `maxPages` or a caller-owned `signal`; `timeoutMs` + * bounds the whole walk. + */ + iterateRunHistory( + automationId: string, + options: BoundedPageOptions, + query: { readonly occurrenceId?: string } = {}, + ): AsyncGenerator { + const defaults = this.#options.operation; + const timeoutMs = object(options) ? options.timeoutMs ?? defaults?.timeoutMs : undefined; + const observer = object(options) ? options.observer ?? defaults?.observer : undefined; + // Pass the filter through as given, so runHistory validates it per page. + const filter: { occurrenceId?: string } = object(query) && Object.hasOwn(query, 'occurrenceId') + ? { occurrenceId: query.occurrenceId as string } : {}; + return iteratePages( + ({ limit, cursor, signal }) => + this.runHistory(automationId, { ...filter, limit, ...(cursor === undefined ? {} : { cursor }) }, { signal }), + object(options) ? { + ...options, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + ...(observer === undefined ? {} : { observer }), + } : options, + ); + } + /** One run and its attempts by run id, read from one producer snapshot. */ async getRun(runId: string, options: OperationOptions = {}): Promise { return await this.#read({ action: 'coven.automations.run.get.v1', id: runId }, options) as CovenAutomationRunResult; @@ -276,13 +327,14 @@ export class CovenAutomationsClient { options: OperationOptions, ): Promise { + CovenAutomationRunResult | CovenAutomationRunHistoryPage | CovenAutomationReceiptResult | CovenAutomationEventPage> { const operation = request.action === 'coven.automations.definition.list.v1' ? 'automations.list' : request.action === 'coven.automations.health' ? 'automations.health' : request.action === 'coven.automations.occurrence.list.v1' ? 'automations.occurrences' : request.action === 'coven.automations.occurrence.history.v1' ? 'automations.occurrenceHistory' : request.action === 'coven.automations.occurrence.get.v1' ? 'automations.getOccurrence' : request.action === 'coven.automations.run.get.v1' ? 'automations.getRun' + : request.action === 'coven.automations.run.history.v1' ? 'automations.runHistory' : request.action === 'coven.automations.receipt.get.v1' ? 'automations.getReceipt' : request.action === 'coven.automations.events.subscribe.v1' ? 'automations.events' : request.action === 'coven.automations.runs' ? 'automations.runs' : 'automations.get'; diff --git a/packages/coven/src/index.ts b/packages/coven/src/index.ts index 3bde523..48aa501 100644 --- a/packages/coven/src/index.ts +++ b/packages/coven/src/index.ts @@ -42,10 +42,13 @@ export type { CovenAutomationOccurrencesResult, CovenAutomationOccurrenceView, } from './automations-occurrences.js'; +export type { CovenAutomationHistoryCursor } from './automations-history.js'; export type { CovenAutomationAttempt, CovenAutomationRun, CovenAutomationRunCancellation, + CovenAutomationRunHistoryOptions, + CovenAutomationRunHistoryPage, CovenAutomationRunsOptions, CovenAutomationRunsResult, } from './automations-runs.js'; diff --git a/tests/coven-automations-platforms.spec.ts b/tests/coven-automations-platforms.spec.ts index 3c446fb..e19fbd2 100644 --- a/tests/coven-automations-platforms.spec.ts +++ b/tests/coven-automations-platforms.spec.ts @@ -238,6 +238,32 @@ describe.each(['unix', 'windows'] as const)('%s Automations parity', (platform) expect(sockets.every((socket) => socket.destroyed)).toBe(true); }); + test.skipIf(platform === 'unix' && process.platform === 'win32')('reads a run history page above 16 KiB within the history bound', async () => { + const { client, configure, sockets } = setup(platform); + const action = 'coven.automations.run.history.v1'; + const runs = Array.from({ length: 60 }, (_, index) => ({ + id: `run-${String(999 - index).padStart(3, '0')}`, automationId: 'morning', occurrenceId: null, + sessionId: null, familiarId: null, runtime: 'coven-code', status: 'succeeded', exitCode: 0, + logJson: 'x'.repeat(200), outputCommit: null, startedAt: '2026-09-14T00:00:00.000Z', + finishedAt: '2026-09-14T00:01:00.000Z', receiptId: null, attempts: [], + })); + const payload = { automationId: 'morning', runs, cursor: { hasMore: false } }; + const body = Buffer.from(JSON.stringify({ + ok: true, accepted: true, action, status: 'completed', event: { kind: 'automations.changed', action, payload }, + })); + expect(body.length).toBeGreaterThan(16_384); + configure.mockImplementation((socket, index) => { + socket.response = index === 0 ? Buffer.from(JSON.stringify(advertisement([action]))) : body; + }); + expect((await client.runHistory('morning', { limit: 100 })).data).toHaveLength(60); + configure.mockImplementation((socket, index) => { + if (index === 2) socket.response = Buffer.from(JSON.stringify(advertisement([action]))); + else socket.rawResponse = Buffer.from('HTTP/1.1 200 OK\r\nContent-Length: 262145\r\n\r\n'); + }); + await expect(client.runHistory('morning')).rejects.toMatchObject({ code: 'body_limit' }); + expect(sockets.every((socket) => socket.destroyed)).toBe(true); + }); + test.skipIf(platform === 'unix' && process.platform === 'win32')('return closes a pending subscription socket before any late response', async () => { const { client, configure, sockets } = setup(platform); configure.mockImplementation((socket, index) => { diff --git a/tests/coven-automations.spec.ts b/tests/coven-automations.spec.ts index c128b04..77a38bb 100644 --- a/tests/coven-automations.spec.ts +++ b/tests/coven-automations.spec.ts @@ -14,6 +14,7 @@ import { type CovenAutomationRunsOptions, type CovenAutomationRunsResult, type CovenAutomationOccurrenceHistoryPage, + type CovenAutomationRunHistoryPage, type CovenAutomationOccurrencesOptions, type CovenAutomationOccurrencesResult, type CovenAutomationOccurrenceResult, @@ -32,7 +33,8 @@ function advertisement() { adapter: 'coven-daemon', status: 'available', policy: 'allow', actions: ['coven.automations.definition.get.v1', 'coven.automations.definition.list.v1', 'coven.automations.health', 'coven.automations.runs', 'coven.automations.occurrence.list.v1', 'coven.automations.occurrence.get.v1', 'coven.automations.run.get.v1', - 'coven.automations.receipt.get.v1', 'coven.automations.occurrence.history.v1'], + 'coven.automations.receipt.get.v1', 'coven.automations.occurrence.history.v1', + 'coven.automations.run.history.v1'], variantNegotiation: { version: 1, contractProfile: 'coven.automations.v1', description: 'Variant negotiation', supported: { @@ -1227,3 +1229,108 @@ test('does not read history when the producer does not advertise the action', as await expect(client.occurrenceHistory('morning')).rejects.toMatchObject({ code: 'capability_unsupported' }); expect(transport.readDefinitions).not.toHaveBeenCalled(); }); + +const runHistoryAction = 'coven.automations.run.history.v1'; + +function runRow(id: string, startedAt: string, occurrenceId: string | null = 'occurrence-1') { + return { ...runSnapshot(), id, occurrenceId, startedAt, attempts: [] }; +} + +function runPage(runs: readonly unknown[], cursor: Record, extra: Record = {}) { + return { automationId: 'morning', ...extra, runs, cursor }; +} + +test('reads a page of run history, optionally for one occurrence', async () => { + const rows = [runRow('run-3', '2026-09-14T00:00:03.000Z'), runRow('run-2', '2026-09-14T00:00:02.000Z')]; + const next = historyCursorFor({ scheduledFor: rows[1]!.startedAt, id: rows[1]!.id }); + const { client, transport } = readSetup(runPage(rows, { hasMore: true, next }), runHistoryAction); + const result = await client.runHistory(' morning ', { limit: 2 }); + expectTypeOf(result).toEqualTypeOf(); + expect(result).toEqual({ automationId: 'morning', data: rows, cursor: { hasMore: true, next } }); + expect(transport.readDefinitions.mock.calls[0]?.[0]).toEqual({ action: runHistoryAction, automationId: ' morning ', limit: 2 }); + + const filtered = readSetup(runPage(rows, { hasMore: false, current: next }, { occurrenceId: 'occurrence-1' }), runHistoryAction); + expect(await filtered.client.runHistory('morning', { occurrenceId: ' occurrence-1 ', cursor: next })).toEqual({ + automationId: 'morning', occurrenceId: 'occurrence-1', data: rows, cursor: { hasMore: false, current: next }, + }); + expect(filtered.transport.readDefinitions.mock.calls[0]?.[0]).toEqual({ + action: runHistoryAction, automationId: 'morning', limit: 20, occurrenceId: ' occurrence-1 ', cursor: next, + }); +}); + +test('orders mixed-precision run history by instant and iterates within maxPages', async () => { + const rows = [ + runRow('run-e', '2026-09-03T09:00:00Z', null), + runRow('run-d', '2026-09-02T09:00:00.000000000Z'), + runRow('run-c', '2026-09-02T09:00:00.000Z'), + runRow('run-b', '2026-09-01T09:00:00.100500000Z'), + runRow('run-a', '2026-09-01T09:00:00.100Z'), + ]; + expect((await readSetup(runPage(rows, { hasMore: false }), runHistoryAction).client.runHistory('morning')) + .data.map((run) => run.id)).toEqual(['run-e', 'run-d', 'run-c', 'run-b', 'run-a']); + await expect(readSetup(runPage([rows[4], rows[3]], { hasMore: false }), runHistoryAction).client.runHistory('morning')) + .rejects.toMatchObject({ code: 'invalid_response' }); + + const { client, transport } = readSetup(); + transport.readDefinitions.mockImplementation((request) => { + const read = request as { cursor?: string; limit: number }; + const start = read.cursor === undefined ? 0 + : rows.findIndex((row) => historyCursorFor({ scheduledFor: row.startedAt, id: row.id }) === read.cursor) + 1; + const slice = rows.slice(start, start + read.limit); + const more = start + read.limit < rows.length; + const last = slice.at(-1)!; + const cursor = { + hasMore: more, + ...(read.cursor === undefined ? {} : { current: read.cursor }), + ...(more ? { next: historyCursorFor({ scheduledFor: last.startedAt, id: last.id }) } : {}), + }; + return Promise.resolve({ + status: 200, body: Buffer.from(JSON.stringify(readEnvelope(runHistoryAction, runPage(slice, cursor)))), + }); + }); + const seen: string[] = []; + for await (const run of client.iterateRunHistory('morning', { limit: 2, maxPages: 5 })) seen.push(run.id); + expect(seen).toEqual(['run-e', 'run-d', 'run-c', 'run-b', 'run-a']); + expect(transport.readDefinitions).toHaveBeenCalledTimes(3); +}); + +const runHistoryCursor = historyCursorFor({ scheduledFor: '2026-09-09T00:00:00Z', id: 'run-9' }); +test.each([ + ['another automation id', { automationId: 'evening' }, {}], + ['an unrequested occurrence echo', { occurrenceId: 'occurrence-1' }, {}], + ['no occurrence echo when filtered', {}, { occurrenceId: 'occurrence-1' }], + ['a run of another occurrence', { occurrenceId: 'occurrence-1', runs: [runRow('run-2', '2026-09-14T00:00:02Z', 'occurrence-2')] }, + { occurrenceId: 'occurrence-1' }], + ['a run of another automation', { runs: [{ ...runRow('run-2', '2026-09-14T00:00:02Z'), automationId: 'evening' }] }, {}], + ['more runs than the limit', { runs: [runRow('run-3', '2026-09-14T00:00:03Z'), runRow('run-2', '2026-09-14T00:00:02Z'), + runRow('run-1', '2026-09-14T00:00:01Z')] }, {}], + ['hasMore without next', { cursor: { hasMore: true } }, {}], + ['a next over 256 characters', { cursor: { hasMore: true, next: 'A'.repeat(300) } }, {}], + ['no current echo', { cursor: { hasMore: false } }, { cursor: runHistoryCursor }], +])('rejects a run history page with %s', async (_label, change, query) => { + const page = { ...runPage([runRow('run-2', '2026-09-14T00:00:02Z'), runRow('run-1', '2026-09-14T00:00:01Z')], { hasMore: false }), ...change }; + await expect(readSetup(page, runHistoryAction).client.runHistory('morning', { limit: 2, ...query })) + .rejects.toMatchObject({ code: 'invalid_response' }); +}); + +test.each([ + ['an empty id', '', {}], + ['a blank occurrence', 'morning', { occurrenceId: ' ' }], + ['a non-string occurrence', 'morning', { occurrenceId: 7 }], + ['an extra field', 'morning', { view: 'due' }], + ['a cursor over 256 characters', 'morning', { cursor: 'A'.repeat(300) }], + ['a non-canonical cursor', 'morning', { cursor: 'AB' }], +])('rejects run history with %s before transport', async (_label, automationId, query) => { + const { client, transport } = readSetup(); + await expect(client.runHistory(automationId, query as never)).rejects.toMatchObject({ code: 'invalid_options' }); + expect(transport.readDefinitions).not.toHaveBeenCalled(); +}); + +test('does not read run history when the producer does not advertise the action', async () => { + const { client, transport } = readSetup(runPage([], { hasMore: false }), runHistoryAction); + const advertised = advertisement(); + advertised.capabilities[0]!.actions = advertised.capabilities[0]!.actions.filter((action) => action !== runHistoryAction); + transport.capabilities.mockResolvedValue({ status: 200, body: Buffer.from(JSON.stringify(advertised)) }); + await expect(client.runHistory('morning')).rejects.toMatchObject({ code: 'capability_unsupported' }); + expect(transport.readDefinitions).not.toHaveBeenCalled(); +});