UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

234 lines (233 loc) • 16.4 kB
import type { WorkflowRun, WorkflowRunStatus } from "../orchestration/workflow.js"; /** * design/97 S1b — the **WorkflowRunStore** persistence seam for the workflow-mode primitive * (`src/orchestration/workflow.ts`, S1a). It is the durable home for a {@link WorkflowRun} so the * `/workflows` observability layer (S1c) can list runs IN PROGRESS *and the history* (completed / failed), * across replicas — the "看之前的" core seam (design/97 §A/§D.1). * * **Distinct from the four load-bearing durable seams** (session / checkpoint / memory / tool-result): those * are the engine's suspend/resume命脉, so core ships PG adapters for them. A WorkflowRunStore is an * **observation layer — opt-in, non-engine-critical** — so per design/97 §D.8 (2026-06-22 拍) core * ships ONLY: this interface + {@link InMemoryWorkflowRunStore} (process-local / default / tests) + * `FileWorkflowRunStore` (zero-dependency, TOC-local persistence — `src/stores/file/workflow-run-store.ts`). * **The PG backend lives in service (TOB), implementing THIS interface** when it needs cross-replica * `/workflows` history. `WorkflowRun` / `WorkflowRunStatus` are imported type-only — this module is purely * the store contract + projection, it does NOT re-home the run model (that stays in `workflow.ts`). * * Mirrors the {@link import("./checkpoint-store.js").CheckpointStore} shape: create-once `put`, `get`, an * **atomic CAS `update`** (scope + `rev` optimistic-concurrency — concurrent agents racing to record progress * on the same run), a `listByScope` returning lightweight {@link WorkflowRunSummary} projections (history * included, NOT pending-only like CheckpointStore.listByScope), and a `reap` retention sweep that only ever * deletes TERMINAL runs (never a running one). The {@link summarizeWorkflowRun} projection is SHARED across * all backends (anti-drift, exactly like `summarizeCheckpoint`). */ /** * A lightweight, read-only projection of one {@link WorkflowRun} returned in bulk by * {@link WorkflowRunStore.listByScope} so the `/workflows` list view (CLI design/92 / client design/90) can * enumerate a scope's runs in ONE call — no N+1 `get`s, no full run payloads. Every field is DERIVED from * the persisted run (nothing here is a new source of truth or a gate input). Built by the shared * {@link summarizeWorkflowRun} so the projection is IDENTICAL across the in-memory, file, and (service) PG * backends. */ export interface WorkflowRunSummary { /** The run id ({@link WorkflowRun.id}). */ id: string; /** The tenant / grouping key this run lives in ({@link WorkflowRun.scope}). */ scope: string; /** The ORIGINATING session id ({@link WorkflowRun.originatingSessionId}), * projected so a serving layer's LIST face can apply its session acceptance/filter arm without an * N+1 `get` of the full run. Absent for a direct `runWorkflow` call / sessionless deployment. */ originatingSessionId?: string; /** design/99 MF-W: the workflow's display name from the script's `export const meta` ({@link WorkflowRun.name}). * Projected here so the `/workflows` list view labels each row WITHOUT an N+1 `get` of the full run. * Absent for a direct `runWorkflow` call that supplied no name. */ name?: string; /** One-line description from the script's meta ({@link WorkflowRun.description}); absent if none. */ description?: string; /** Lifecycle status ({@link WorkflowRun.status}): running / completed / failed. */ status: WorkflowRunStatus; /** The MACHINE code of the failure that ended the run ({@link WorkflowRun.errorCode}) — the same word, * in the same vocabulary, that the full run carries; never re-derived and never a second taxonomy. * The fourth member of the same family as {@link agentFailures} / {@link budgetOvershoot} / * {@link timeoutInterruption}: those three qualify a row that LOOKS clean, and this one says WHY a * `failed` row failed. Nothing else on this projection can answer that — {@link status} is `failed` * for a refused spawn, a blown budget, a stalled run and a body that simply threw alike, so a list * face without this seat must `get` the full run per failed row to say anything at all, which is the * N+1 this projection exists to avoid. * * PRESENT EXACTLY WHEN THE RUN CARRIES ONE, absent otherwise — an absent key reads "no code on the * record", never "the run succeeded" and never a `workflow.unknown` stand-in. A `completed` run never * carries one (per {@link WorkflowRun.errorCode}), and a `failed` run whose body threw a codeless * error with no failed child on the record carries none either. Free of any own attribution rule: the * attribution (thrown code → first failed agent → absent) happens once, where the run is written. */ errorCode?: string; /** Count of agent-runs that ended failed ({@link WorkflowRun.agentFailures}) — present only when > 0. * Lets a list view flag a "completed, with failures" run without an N+1 `get` of the full run. */ agentFailures?: number; /** The run's TERMINAL token-budget overshoot ({@link WorkflowRun.budgetOvershoot}) — present ONLY when the * run actually spent MORE than its ceiling, absent on every run that stayed within one and on every run * that set no budget at all. The list-view twin of {@link agentFailures}: it lets a row be flagged * "completed, and over budget" without an N+1 `get` of the full run — the *other* way a `completed` row is * not the unqualified good news it looks like. It cannot be DERIVED from anything else on this projection: * {@link tokens} is a different axis (see its note), so without this seat an over-budget run reads on the * list face exactly like a clean one. * * Projected as a de-aliased shallow COPY — every other member here is a primitive, so the projection must * not hand a caller a live reference into a stored run (the same anti-aliasing posture the stores' `get` * keeps with `structuredClone`). Read the members per {@link WorkflowRun.budgetOvershoot}: the overshoot is * `spentTokens + unsettledTokens - budgetTokens`, and a run resting entirely on `unsettledTokens` is a * best-effort disclosure rather than a measurement. */ budgetOvershoot?: { budgetTokens: number; spentTokens: number; unsettledTokens?: number; }; /** The run's TERMINAL total-timeout interruption ({@link WorkflowRun.timeoutInterruption}) — present ONLY * when the `totalTimeoutMs` deadline had fired by the time the run reached its terminal. The third member * of the same family as {@link agentFailures} and {@link budgetOvershoot}: it lets a list row separate * "this run was CUT SHORT, and here is what it had achieved" from "this run failed", without an N+1 `get` * of the full run. Nothing else on this projection can answer that — {@link status} is `failed` either * way, and {@link agentCount} counts records regardless of how they ended. * * Projected as a de-aliased shallow COPY (same anti-aliasing posture as {@link budgetOvershoot}: a list * row must not be a mutable window into the stored run). Read the members per * {@link WorkflowRun.timeoutInterruption} — in particular `agentsFailed` is a PRE-abandonment count and is * NOT {@link agentFailures}, and `approvalWaitedMs` (#485, present only when a leg reported one) is the * LARGEST single leg's human-approval wait, a lower bound on how much of the window went on a person * rather than on work — never a sum, and its ABSENCE is "not supplied", never `0`. */ timeoutInterruption?: { timeoutMs: number; agentsCompleted: number; agentsFailed: number; agentsInFlight: number; approvalWaitedMs?: number; }; /** Title of the latest phase recorded ({@link WorkflowRun.phases}`.at(-1).title`) — what the run is on RIGHT * NOW for a `running` row (so the `/workflows` list shows the live phase without subscribing to the event * stream). Absent when no phase has started yet. */ currentPhase?: string; /** Number of phases recorded so far ({@link WorkflowRun.phases}`.length`). */ phaseCount: number; /** Number of agent-runs recorded so far ({@link WorkflowRun.agents}`.length`). */ agentCount: number; /** Total tokens spent = own + nested (`stats.tokens + stats.nested.tokens`) — the figure a triage view * sorts/compares by. own/nested stay SEPARATE on the full run (R-5); the summary folds them for display. * ⚠️ NOT the budget axis's figure: run stats also count REPLAYED work, which the budget deliberately never * charges, so comparing this against a ceiling proves nothing in either direction — whether a run overran * its budget is read off {@link budgetOvershoot}, never inferred from here. */ tokens: number; /** When the run started ({@link WorkflowRun.startedAt}, epoch ms). */ startedAt: number; /** When the run finished ({@link WorkflowRun.endedAt}, epoch ms) — absent while still running. */ endedAt?: number; /** When the run record was created ({@link WorkflowRun.createdAt}, epoch ms) — the `listByScope` sort key. */ createdAt: number; } /** * design/97 S1b: project a {@link WorkflowRun} to its lightweight {@link WorkflowRunSummary}. SHARED by every * {@link WorkflowRunStore} impl so the projection is IDENTICAL across the in-memory, file, and (service) PG * backends (the anti-drift guard — exactly like `summarizeCheckpoint`). Pure; reads no clock/random. Total * spend folds own + nested (`stats.tokens + stats.nested.tokens`), the one place the two are summed. */ export declare function summarizeWorkflowRun(run: WorkflowRun): WorkflowRunSummary; /** Whether a run is in a TERMINAL state — the ONLY states {@link WorkflowRunStore.reap} may delete (a * `running` run is never reaped, so an in-flight workflow's record can't vanish out from under it). */ export declare function isTerminalWorkflowStatus(status: WorkflowRunStatus): boolean; /** A typed workflow-run-store error so callers branch on `code` (mirrors `CheckpointError`). */ export declare class WorkflowRunStoreError extends Error { readonly code: "workflow_run.already_exists"; constructor(code: "workflow_run.already_exists", message: string); } /** REF-D18: `listByScope`'s query options — the SINGLE shape the interface method and both the * In-Memory and File implementations reference. */ export interface WorkflowRunListByScopeOptions { status?: WorkflowRunStatus; limit?: number; session?: string; } /** * The pluggable persistence seam for workflow runs (design/97 S1b). Symmetric with `CheckpointStore`: * create-once `put`, `get`, an atomic CAS `update`, a `listByScope` query (history included), and a `reap` * retention sweep. A durable backend (service's PG) makes `/workflows` history cross-replica; the default * {@link InMemoryWorkflowRunStore} is process-only (single instance / tests). */ export interface WorkflowRunStore { /** Create-once. Throws {@link WorkflowRunStoreError} `already_exists` on a token collision (never a silent * overwrite — a reused id would clobber a live run's record). */ put(id: string, run: WorkflowRun): Promise<void>; get(id: string): Promise<WorkflowRun | null>; /** * Atomic CAS update of an EXISTING run (`UPDATE … SET … WHERE id=? AND scope=?` [`AND rev=?`]). Returns * `true` for the winner, `false` when the row is missing, the `scope` does not match (multi-tenant * isolation — a wrong-scope update must not win), or `expect.rev` does not match the live row's `rev` * (an intervening update bumped it). On success the store bumps the row's `rev` (monotonic) and persists * the new `run`. The workflow owner is the sole writer (best-effort progress recording — design/97 §F), * so `expect.rev` lets it detect a lost write rather than silently clobber. */ update(id: string, scope: string, run: WorkflowRun, expect?: { rev: number; }): Promise<boolean>; /** * List lightweight {@link WorkflowRunSummary} projections for runs in `scope` — **history included** (the * completed / failed runs, NOT pending-only like `CheckpointStore.listByScope`), so this is the "看之前的" * core query. Newest first (`createdAt` DESC). `opts.status` filters to one lifecycle state; `opts.limit` * caps the result count (applied AFTER the sort, so it keeps the newest N). An empty scope returns `[]`. */ /** `opts.session`: STORE-side push-down filter on * `originatingSessionId` — strict equality, rows WITHOUT the field excluded (filter semantics, * deliberately unlike the per-id acceptance arm's absent-pass). Applied BEFORE limit, so the * serving layer's "this session's workflows" is FULL-history, not page-window (cli note). */ listByScope(scope: string, opts?: WorkflowRunListByScopeOptions): Promise<WorkflowRunSummary[]>; /** * Retention sweep: delete OLD **terminal** runs in `scope` (a `running` run is NEVER deleted — design/97 * §D.1/§F). Returns the count deleted (for metrics). Retention is by either policy (apply BOTH when both * given — a run is reaped if it fails either bound): * - `opts.maxAgeMs`: delete a terminal run whose `endedAt` (else `createdAt`) is older than `now - * maxAgeMs`. * - `opts.keep`: keep only the newest `keep` terminal runs (by `createdAt` DESC); delete the rest. * With NEITHER option set, nothing is deleted (a no-op `0`) — retention is always an explicit policy. */ reap(scope: string, now: number, opts?: { maxAgeMs?: number; keep?: number; }): Promise<number>; } /** * REF-A5 — THE optimistic-concurrency guard for {@link WorkflowRunStore.update}, shared by every backend: * scope in the WHERE (multi-tenant isolation — a wrong-scope update never wins), row must exist, and an * `expect.rev` that no longer matches the LIVE row loses (an intervening update bumped it). Returns the * row to persist, or `null` when the CAS lost; PURE — it neither reads nor writes any store state, so * each backend keeps its own persistence mechanism (in-memory `Map.set` vs ledger commit) around it. * RB-59 (a cross-instance CAS both instances won) landed on this predicate. */ export declare function nextWorkflowRunOnUpdate(cur: WorkflowRun | undefined, id: string, scope: string, run: WorkflowRun, expect?: { rev: number; }): WorkflowRun | null; /** * REF-A5 — THE `listByScope` query projection, shared by every backend: scope/status/session filter → * newest-first by `createdAt` → truncate → {@link summarizeWorkflowRun}. Pure over any row iterable, so * the in-memory backend passes its `Map` values and the file backend passes the replayed ledger's. */ export declare function queryWorkflowRuns(runs: Iterable<WorkflowRun>, scope: string, opts?: WorkflowRunListByScopeOptions): WorkflowRunSummary[]; /** * Default in-process {@link WorkflowRunStore}. Single-instance / tests only — it does NOT survive a restart * or span replicas. Single-threaded JS already serializes `update`, so the CAS is trivially atomic here; * `structuredClone` on the boundaries prevents aliasing (a later mutation of the caller's object — the live * `WorkflowRun` the workflow keeps mutating — must not corrupt the stored row, and a returned run must not * be a live reference the caller can mutate back into the store). */ export declare class InMemoryWorkflowRunStore implements WorkflowRunStore { private runs; put(id: string, run: WorkflowRun): Promise<void>; get(id: string): Promise<WorkflowRun | null>; update(id: string, scope: string, run: WorkflowRun, expect?: { rev: number; }): Promise<boolean>; listByScope(scope: string, opts?: WorkflowRunListByScopeOptions): Promise<WorkflowRunSummary[]>; reap(scope: string, now: number, opts?: { maxAgeMs?: number; keep?: number; }): Promise<number>; /** Test/inspection helper: number of stored runs. */ get size(): number; }