/** * P1 (fleet failover correctness, clay 2026-07-05) — SINGLE-FILE DUAL-DIALECT ([ref] A12 定型半场). * ONE implementation, TWO dialects; the historical `TiDBWorkflowRunStore`/`PgWorkflowRunStore`, * `TiDBWorkflowCompletionInbox`/`PgWorkflowCompletionInbox`, `TiDBWorkflowNotifyJournalStore`/ * `PgWorkflowNotifyJournalStore` class names survive as thin ctor subclasses so every consumer * (store-backend.ts, the DB-integration + slim-oversize-run suites) is untouched. * * These are the TiDB/PG twins of core's `WorkflowRunStore` and the service `WorkflowCompletionInbox`, * closing the LAST replica-local gap in the fleet story: dispatch failover (taskId idempotency + * announce/lease, 1.98–1.103) can land a session on ANOTHER instance, where a File-backed workflow * record / completion inbox strands the run's history and its pending push. * * ## WorkflowRunStore twin * One row per run: the full `WorkflowRun` as a JSON blob + EXTRACTED columns for everything SQL needs to * index/filter (scope, status, created_at_ms, ended_at_ms) + an authoritative `rev` column (the OCC key — the * blob's own `rev` is OVERLAID from the column on every read, so writes never have to know the bumped value * up front). `update` is a single-statement CAS (`WHERE id AND scope [AND rev]`, `SET rev = rev + 1`); * `listByScope` projects через core's SHARED `summarizeWorkflowRun` (anti-drift — identical to InMemory/File). * * ## Completion inbox twin * The File inbox's fences (purge / poll-served) are in-memory BY DESIGN there (single box); here they are * ROWS (`workflow_inbox_fence`), which is the actual point of the port — a poll served on replica A must * fence the late notify landing on replica B, or the double-push returns the moment a fleet has two * replicas. Fences are swept opportunistically on every write verb. Entry order rides an auto-incrementing * `seq` (re-enqueue = atomic move-to-tail → fresh seq = tail, matching the File/InMemory Map semantics; 1.108 * review hardening: post-insert fence RE-CHECK closes the cross-replica check-then-insert TOCTOU). * * ## Notify-journal twin * 1.108 review fix (lens③ HIGH): with a SQL run store + inbox, a replica-local File journal was the last * replica-local piece of the at-least-once completion-notify chain (a replica that died holding an un-acked * entry stranded the notify forever). Semantics mirror `FileWorkflowNotifyJournalStore` verb-for-verb: * `record` is idempotent and NEVER resets an acked row; `ack` flips pending→acked once; `listPending` is the * recovery backlog (any replica may sweep it — the receiver is idempotent on runId, so a double recovery * across replicas is at-least-once, not N-times). * * ── Dialect deltas, kept EXPLICIT ──────────────────────────────────────────────────────────────────── * - `?` placeholders vs `$n` (and the OPTIONAL `AND rev = ?` / `AND rev = $6` OCC tail on `update`) * - create-once dup-key: errno-string `ER_DUP_ENTRY` vs SQLSTATE `23505` * - `listByScope`'s optional-session filter: `JSON_UNQUOTE(JSON_EXTRACT(run, '$.originatingSessionId'))` * vs `run::json->>'originatingSessionId'` — AND the placeholder-numbering shape that follows (PG's `$n` * is POSITIONAL and must track `params.length` as each optional clause is appended; TiDB's `?` is not), * so this method keeps an explicit tidb/pg branch rather than a single `q()` pair. * - completion-inbox move-to-tail: TiDB `REPLACE INTO …` (atomic delete-conflicting-row + insert, omitted * `seq` mints a fresh AUTO_INCREMENT = tail) vs PG `INSERT … ON CONFLICT … DO UPDATE SET seq = nextval(…)` * (re-mint `seq` off the BIGSERIAL's own sequence) — same outcome, structurally different statements. * - the inbox overflow-scan needs `LIMIT 100000 OFFSET ?` on TiDB (MySQL-protocol requires a LIMIT * alongside OFFSET) but plain `OFFSET $2` on PG. * - bulk delete-by-id-set: `id IN (?,?,…)` (dynamic placeholder count) vs `id = ANY($n::text[])` / * `seq = ANY($n::bigint[])` (array bind) — both twins do this for `reap` and the inbox overflow drop. * - `run` is TEXT (not jsonb) on PG — matches TiDB's plain string column, so JSON binding is * `JSON.stringify` verbatim on BOTH dialects (no `pgProtocolJsonStringify` envelope needed here; that * concern belongs to stores whose column IS jsonb, e.g. checkpoint-store-sql.ts). * - affectedRows vs rowCount (via SqlDriver's normalized `affected`). * - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL centrally in pg-pool.ts (neither store creates * tables). */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { summarizeWorkflowRun, type WorkflowRunStore } from "@sema-agent/core"; import { type WorkflowCompletionInbox, type WorkflowCompletionInboxEntry } from "../orchestration/workflow-completion-inbox.js"; import type { WorkflowNotifyJournalStore, WorkflowNotifyJournalEntry } from "../orchestration/workflow-notify-journal.js"; import { type WorkflowAgentSessionIndex, type WorkflowAgentSessionRow } from "../orchestration/workflow-agent-session-index.js"; import { type SqlDriver } from "./sql-driver.js"; type WorkflowRun = Parameters[1]; /** Row cap guard: a run blob beyond this is NOT stored on update (the prior revision stays) — TiDB's * ~6 MiB txn-entry limit would otherwise throw the (best-effort) progress write into the workflow loop. * `put` (create, small) still throws on a real failure. Mirrors the journal's oversize skip-degrade. */ export declare const MAX_RUN_BLOB_BYTES: number; declare const onWarnType: (msg: string, meta: Record) => void; export type InboxWarn = typeof onWarnType; /** S-107:workflow_run 行的 `error`(脚本抛出的 `err.message`,core 源头不脱)在**两条写腿**(put / update 常态 + oversize 降级) * 同一口脱;`result` 由 core 完成时源头脱。幂等,不抛(超大 ⇒ 占位)。 */ export declare function withRedactedError(run: T): T; /** Review fix (1.108, lens④ HIGH): an oversize blob must NOT fail the update outright — core's persist chain * treats `false` as a CAS loss and does NOT advance its tracked rev, so once a run outgrows the cap EVERY later * write (including the TERMINAL one) keeps failing → the durable row is stuck `running` forever and the 24h * orphan sweep finalizes a genuinely-completed run as `abandoned`. Instead degrade the PAYLOAD, not the write. * * 🔴 **`parks[]` 是这条阶梯唯一不许碰的东西**(core 7.17.0 [ref] 义务②)。 * 它不是观测位,是**下一次 resume 的准入输入**:core 的 resume 准入在任何 agent 被派发之前读记录上的 * 这一位。🔴 **7.78.0 / core 7.18.0([ref])口径更新**:读不到**不再**是整条拒 —— core 改成三臂,缺席时 * 从 journal 的 `parked` 臂 + 记录自己 `parked` 腿行的 `parkedCheckpointToken` 派生候选逐个验真,非空即 * 准入,空才拒(`parks_unreadable` 条件收窄到那一格)。但**本店剥它仍是灾难级**:带 `parks` 的记录走 * 第一臂,「那张表独自作数」—— 剥成缺席就把一条走第一臂的记录推去走派生臂,而派生的两个缓存只装这条 run * **自己做出**的 park,继承来、从未跑到的那一条**只存在于被剥掉的那张表里**,于是它要么被漏掉、要么被 * 当成不存在而在 resume 时活着重跑([ref] 正要消灭的形)。它同时也是本仓 `/decide` 的 workflow 车道复核 * 「这条 park 属于这条 run」的**单一真源**(S-252)。实现上三级全部走 `{...上一级}` 展开、没有任何一级点名裁它,机器背书 = * `test/slim-oversize-run.test.ts` 里那两格(最狠的一级上 `parks` 仍逐字完整 + 负控:记录本来没有 * 这一位时不许凭空补 `[]`)。缺席 ⇒ 不补:补 `[]` 会把「这版引擎没写这个位」伪造成「这条 run 没有 park」, * 而 core 对两者的处置相反。 * * 🔴 **S-252(7.75.0):阶梯不再为 park 另存第二份恢复身份。** 7.71.0 的 S-192 曾在 `agents[]` 里逐行保住 * `callKey`/`sessionId`/`parkedCheckpointToken`(外加两级 `label` 预算与一枚不可恢复标记),因为当时 * `/decide` 的复核只认那一行。core 7.17.0 之后同一条记录上有了 `parks[]`,那份保留就成了**第二个会漂的 * 定义**(真案:最后一级清空 `agents` 之后 token 仍躺在同一个 blob 的 `parks` 里,而 `/decide` 照拒)。 * ⇒ 整段退役,判别式改读单一真源。退役后的阶梯每一级都**不大于**退役前的对应级,物理下界不劣化。 * * 阶梯 = 一张**按价值排序的放弃清单**,一级一级试到装得下为止: * step 1 —— 两个无界字符串(`result` / `error`)截断带标记; * step 2 —— 展示载荷整段丢(`phases` / `agents` / `groups`),`error` 收成短串; * step 3 —— 顶层**观测载荷**:`effectiveArgs` 与 `resultFull` 整段丢、`name` / `description` 先脱后切。 * core 对 `effectiveArgs` 的成文口径逐字是 OBSERVATION-ONLY(never a gate input,never re-read by * the engine;resume 的身份由 journal 的 callKey 携带,不是这个字段),`resultFull` 同类(取回面), * 两者都**无界**;`name` / `description` 是脚本(LLM 撰写)经 `export const meta` 供的展示头, * 同样无界 ⇒ **先脱后切**(切口会把读面脱敏所依赖的闭合引号一起切掉,半截凭据原样上 wire)。 * Returns null only when even the marked skeleton is oversize (physics — caller keeps the prior revision, * matching the old behavior for that corner). Dialect-neutral (pure JS, no SQL) — shared verbatim by both twins. */ export declare function slimOversizeRun(run: WorkflowRun & { id: string; scope: string; }, maxBytes: number): string | null; /** Dual-dialect durable `WorkflowRunStore`. See the file header for the dialect-delta ledger. * `onWarn` (optional, same shape as the completion-inbox's {@link InboxWarn}) is the C5 trace channel for * `update`'s oversize-slim degrade (see there) — omit it and construction/behavior is unchanged (additive). */ export declare class SqlWorkflowRunStore implements WorkflowRunStore { protected readonly db: SqlDriver; private readonly onWarn?; constructor(db: SqlDriver, onWarn?: InboxWarn | undefined); /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */ private q; put(id: string, run: WorkflowRun): Promise; get(id: string): Promise; update(id: string, scope: string, run: WorkflowRun, expect?: { rev: number; }): Promise; listByScope(scope: string, opts?: { status?: WorkflowRun["status"]; limit?: number; session?: string; }): Promise[]>; /** Retention (the store never auto-purges; reap is explicit): the contract's `reap` is scope-keyed and the * contract deliberately has NO cross-scope enumeration — but the SQL twin CAN enumerate (DISTINCT on the * extracted scope column), which is exactly what a periodic deployment sweep needs. Walks every scope through * the contract-faithful `reap` (terminal-only; running rows are the orphan-grace sweep's business). Service * extension, not a contract member. Byte-identical text on both dialects (no params) — no q() needed. */ reapAllScopes(now: number, opts: { maxAgeMs?: number; keep?: number; }): Promise; reap(scope: string, now: number, opts?: { maxAgeMs?: number; keep?: number; }): Promise; } /** Dual-dialect `WorkflowCompletionInbox`. See the file header for the dialect-delta ledger. */ export declare class SqlWorkflowCompletionInbox implements WorkflowCompletionInbox { protected readonly db: SqlDriver; private readonly onWarn?; constructor(db: SqlDriver, onWarn?: InboxWarn | undefined); private q; private sweepFences; enqueue(entry: WorkflowCompletionInboxEntry): Promise; pending(sessionId: string): Promise; ack(sessionId: string, runId: string): Promise; purge(sessionId: string, purgedOwner?: string | null): Promise; markTerminalServed(sessionId: string, runId: string): Promise; } /** * Dual-dialect `WorkflowNotifyJournalStore` — the crash-safe recovery backlog for at-least-once completion * notify. See the file header for the dialect-delta ledger. */ export declare class SqlWorkflowNotifyJournalStore implements WorkflowNotifyJournalStore { protected readonly db: SqlDriver; constructor(db: SqlDriver); private q; record(input: { runId: string; scope: string; sourceTaskId?: string; principal?: string; createdAt: number; }): Promise; ack(runId: string, ackedAt: number): Promise; listPending(): Promise; /** Retention (same sweep as reapAllScopes): ACKED rows are pure history — without this the twin re-opens * the unbounded-growth hole the same release closed for workflow_run. Pending rows are NEVER reaped (they * are the recovery backlog; the orphan-grace sweep is what retires a stuck pending run). */ reapAcked(before: number): Promise; get(runId: string): Promise; } /** * S-185 车CM —— **workflow 出身 park 的有界 join 索引** SQL 双生(契约与纪律的唯一属主 = * {@link import("../orchestration/workflow-agent-session-index.js").WorkflowAgentSessionIndex} 的文件头, * 这里只记方言差)。行是 `WorkflowRun` 的派生投影,写者只有一个(Journaling 装饰器的写观察点)。 * * ── 方言差,保持显式 ────────────────────────────────────────────────────────────────────────── * - `?` 占位 vs `$n`,且 `syncRun` 的删除腿在 PG 上要按 `params.length` 记位(与 `listByScope` 同因)。 * - 「删掉这条 run 里已不在 rows 里的旧行」:TiDB `session_id NOT IN (?,?,…)`(动态占位数)vs * PG `session_id <> ALL($n::text[])`(数组绑定)—— 两边同一件事,语句结构不同(reap 的批删同先例)。 * - upsert:TiDB `REPLACE INTO`(整行替换)vs PG `INSERT … ON CONFLICT (session_id) DO UPDATE`。 * - schema 属主同店内其余表:TiDB DDL 在 tidb-pool.ts,PG DDL 在 pg-pool.ts(店不建表)。 * * 🔴 **写序**:先 upsert 再删。反过来(先删后 upsert)会在两条语句之间留出一个**索引行不存在**的窗口, * 而这期间进来的 `/decide` 会诚实 miss ⇒ 一次本可受理的批准被退回。先 upsert 的最坏形是窗口内多一条 * 旧行(读口按 session_id 精确查,取 run 后还要逐字比对赎回键 ⇒ 多余的行只会 miss,不会错配)。 */ export declare class SqlWorkflowAgentSessionIndex implements WorkflowAgentSessionIndex { private readonly db; constructor(db: SqlDriver); private q; syncRun(runId: string, rows: readonly WorkflowAgentSessionRow[]): Promise; findByAgentSession(sessionId: string): Promise; } /** MySQL-protocol (TiDB) bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd * ctor arg (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */ export declare class TiDBWorkflowRunStore extends SqlWorkflowRunStore { constructor(pool: MySqlPool, onWarn?: InboxWarn); } export declare class TiDBWorkflowCompletionInbox extends SqlWorkflowCompletionInbox { constructor(pool: MySqlPool, onWarn?: InboxWarn); } export declare class TiDBWorkflowNotifyJournalStore extends SqlWorkflowNotifyJournalStore { constructor(pool: MySqlPool); } /** PostgreSQL bindings — historical class names preserved; `onWarn` is an ADDITIVE optional 2nd ctor arg * (existing 1-arg call sites are unaffected — see {@link SqlWorkflowRunStore}'s C5 trace channel). */ export declare class PgWorkflowRunStore extends SqlWorkflowRunStore { constructor(pool: PgPool, onWarn?: InboxWarn); } export declare class PgWorkflowCompletionInbox extends SqlWorkflowCompletionInbox { constructor(pool: PgPool, onWarn?: InboxWarn); } export declare class PgWorkflowNotifyJournalStore extends SqlWorkflowNotifyJournalStore { constructor(pool: PgPool); } export declare class TiDBWorkflowAgentSessionIndex extends SqlWorkflowAgentSessionIndex { constructor(pool: MySqlPool); } export declare class PgWorkflowAgentSessionIndex extends SqlWorkflowAgentSessionIndex { constructor(pool: PgPool); } export {}; //# sourceMappingURL=workflow-run-store-sql.d.ts.map