/** * Durable {@link WorkflowJournalStore} (SVC-2, core CORE-7) — SINGLE-FILE DUAL-DIALECT ([ref] A12 * 定型半场). ONE implementation, TWO dialects; the historical `TiDBWorkflowJournalStore` / `PgWorkflowJournalStore` * class names survive as thin ctor subclasses so every consumer (store-backend.ts, the extreme/integration/ * resume-fork-probe suites) is untouched. * * This is the cross-replica resume journal for an LLM-authored workflow. core ships * {@link import("@sema-agent/core").InMemoryWorkflowJournalStore} (single-replica, lost on crash); this is the * byte-match durable twin so a workflow survives a replica crash and RESUMES (replay the longest unchanged * PREFIX of recorded `ctx.agent` results, run only the first changed/new call + everything after it live). The * journal is LOAD-BEARING (unlike the best-effort {@link import("./run-store-sql.js")} observation layer): * append/load failures MUST throw — a silently-dropped entry would silently re-run already-done work. * * Schema = `workflow_journal (run_id, ordinal) PK` (DDL in tidb-pool.ts / pg-pool.ts ensureSchema). `ordinal` * is the positional index a callKey was minted at, derived with core's {@link callKeyOrdinal} (NEVER * hand-parsed — a malformed key falls back to ordinal 0 there, matching the InMemory store). `result` holds * `JSON.stringify(entry.result)` (a TaskResult is plain serializable data). The PK makes append IDEMPOTENT per * (run_id, ordinal): a second append for the same ordinal OVERWRITES (last-write-wins) — a resumed run * re-appends its replayed prefix verbatim, so this is the exact InMemory `byOrdinal.set(ordinal, …)` semantics, * not a duplicate row. * * ── Dialect deltas, kept EXPLICIT ──────────────────────────────────────────────────────────────────── * - `?` placeholders vs `$n` * - `ON DUPLICATE KEY UPDATE … VALUES(col)` vs `ON CONFLICT (run_id, ordinal) DO UPDATE SET … EXCLUDED.col` * - `LENGTH(result)` (TiDB — byte length) vs `OCTET_LENGTH(result)` (PG — `LENGTH(text)` is CHARACTER count; * a multi-byte UTF-8 payload would silently slip past the byte-budget cap on PG with plain `LENGTH`, so * the twin uses the byte-counting function instead — codex R3, real dual-DB-suite-confirmed) in `loadPage`'s * size-gate CASE. * - `loadPage`'s bind-param ORDER: TiDB's `?`s appear in query-text order (maxResultBytes first, inside the * CASE, ahead of runId/scope), so its params array is `[maxResultBytes, runId, scope, limit, offset]`; PG's * `$n` are POSITIONAL (not text-order), so its array is `[runId, scope, maxResultBytes, limit, offset]` * (`$3` inside the CASE refers to the third element regardless of where `$3` sits in the text). Same * placeholder-arity-fallout class the sql-driver.ts header calls out for null-safe compares. * - affectedRows vs rowCount (via SqlDriver's normalized `affected`). * - schema ownership: TiDB DDL in tidb-pool.ts, PG DDL in pg-pool.ts (neither store creates tables). */ import type { Pool as MySqlPool } from "mysql2/promise"; import type { Pool as PgPool } from "pg"; import { type WorkflowJournalEntry, type WorkflowJournalStore } from "@sema-agent/core"; import { type SqlDriver } from "./sql-driver.js"; /** Dual-dialect durable WorkflowJournalStore. See the file header for the dialect-delta ledger. */ /** [ref] 租约旋钮。TTL 只是**崩溃兜底**(engine 终态 finally 显式释放;[ref]/[ref] 两层分工: * engine 崩了没释放 ⇒ 陈旧 claim 可被接管)。 * 🔴 缺省 1h,与 core file 参考实现同值([ref] :284)——**租约无心跳**(claimed_at_ms 在授予时刻定格, * 运行期间不刷新),所以 TTL 必须盖过最长合法 run 时长:取小了(我初版 15min)会在一次 >TTL 的活跑 * 中把 claim 判陈旧、放另一副本进来接管——恰是本缝要防的双跑。要更短的接管等待,先给 engine 半场 * 加心跳刷新,再谈调小。 */ export interface SqlWorkflowJournalStoreOptions { resumeClaimTtlMs?: number; } export declare class SqlWorkflowJournalStore implements WorkflowJournalStore { private readonly db; private readonly resumeClaimTtlMs; constructor(db: SqlDriver, opts?: SqlWorkflowJournalStoreOptions); /** Pick the dialect's SQL text. Both statements stay written out at the call site ON PURPOSE. */ private q; /** Record one agent's result. Idempotent per (run_id, ordinal): the PK upsert OVERWRITES (last-write-wins), so a * resumed run re-appending its replayed prefix verbatim leaves no duplicate. callKeyOrdinal is the SOLE ordinal * source (core's parser — a malformed key collapses to 0, matching InMemory). Throws on a REAL failure * (LOAD-BEARING) — an oversize entry included, on BOTH arms (S-194 / core 7.12.0 [ref]; see below). */ append(runId: string, scope: string, entry: WorkflowJournalEntry): Promise; /** Entries for `runId` IF its recorded scope === `scope`, ASCENDING by ordinal; otherwise EMPTY. CORE-9 audit * BLOCKER: scope in the WHERE (twin of WorkflowRunStore) — the LLM controls `resumeFromRunId`, so a cross-scope * runId resolves to an empty journal (resume safely diverges to live, never discloses another tenant's results). * Each row's `result` is the persisted JSON.stringify(TaskResult) → JSON.parse back. Throws on failure (LOAD-BEARING). */ load(runId: string, scope: string): Promise; /** [ref] locator 云形(core 1.353 WorkflowJournalStore.locator additive):SQL 店的 journal 坐标= * [ref] HTTP 读面路由(file 店返 jsonl 路径=CC 本地形;此处返云形,core 铸 diagnostics 教句时单源引用)。 */ locator(runId: string, _scope: string): string; /** codex R1-H2:分页投影读——LIMIT/OFFSET 界行数,字节门界单行字节(超限行不取 result 全文,只回字节数; * 读面标 truncated)。ORDER BY ordinal 与 load 同轴。 * 🔴 param ORDER differs by dialect (see file header): TiDB's `?`s bind in text order (maxResultBytes first, * inside the CASE); PG's `$n` are positional, so its array keeps runId/scope first. Never unify — a swap * silently mis-binds the OTHER dialect's placeholders. */ loadPage(runId: string, scope: string, opts: { offset: number; limit: number; maxResultBytes: number; }): Promise>; /** GC — purge ALL journal entries for a run (the run reaper calls this once the run is terminal + retained). Scoped * by run_id alone; idempotent (affected-row count). Mirrors the resume-anchor store's deleteBySession. */ deleteByRun(runId: string): Promise; /** [ref] / WF2([ref] core 拍板 a 形,签名逐字)—— cross-replica resume admission lease。 * * 语义(bake-store `idem_key UNIQUE` 先例):PK (source_run_id, scope) 上的原子赢或观察。四步,每步 * 单语句原子,并发交叉在任一步都收敛到「恰一个持有者」: * ① 抢空位:INSERT..DO NOTHING / ON DUP KEY 无操作 —— affected=1 即赢; * ② 同持有者幂等重入(engine 重试同一 resume):按 (键, new_run_id) 守卫的 claimed_at_ms 刷新; * ③ TTL 崩溃兜底接管:claimed_at_ms < now-ttl 守卫下的原子改持有者(engine 终态会显式释放, * 走到这步=上一持有 engine 崩了没释放;两层分工见 SqlWorkflowJournalStoreOptions 注); * ④ 都没赢 ⇒ 读在位者返 {granted:false, holder}(holder 进 engine 的拒绝文案供归因)。 */ resumeClaim(input: { sourceRunId: string; newRunId: string; scope: string; }): Promise<{ granted: boolean; holder?: string; }>; /** 只有 holder 能释放(new_run_id 守卫)—— engine 在 newRunId 终态/放弃时调;非 holder 调用=no-op。 */ releaseResumeClaim(input: { sourceRunId: string; newRunId: string; scope: string; }): Promise; /** GC — time-based sweep: purge journal entries older than `maxAgeMs` (created_at_ms < now - maxAgeMs). The * per-run deleteByRun has no production caller (a run-store reap gives no per-run hook), so this bounded sweep * — wired into the service reaper — is what keeps the heaviest table (TaskResult-bearing) from growing without * bound. A resume of a journal older than the retention window simply re-runs live (resume is an optimization). * Idempotent; returns the rows purged. */ reapExpired(now: number, maxAgeMs: number): Promise; } /** MySQL-protocol (TiDB) binding — historical class name + ctor shape preserved. */ export declare class TiDBWorkflowJournalStore extends SqlWorkflowJournalStore { constructor(pool: MySqlPool, opts?: SqlWorkflowJournalStoreOptions); } /** PostgreSQL binding — historical class name + ctor shape preserved. */ export declare class PgWorkflowJournalStore extends SqlWorkflowJournalStore { constructor(pool: PgPool, opts?: SqlWorkflowJournalStoreOptions); } //# sourceMappingURL=workflow-journal-store-sql.d.ts.map