UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

607 lines (606 loc) • 34 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { randomUUID } from "node:crypto"; import { NotFoundError, UsageError } from "../../core/errors.js"; import { openStateDatabase, withImmediateTransaction } from "../../core/state-db.js"; import { borrowScopedStateDb, withStateDbScope } from "../../core/state-db-scope.js"; import { WORKFLOW_PLAN_VERSION } from "../../workflows/plan.js"; import { escapeLikePattern } from "../like-pattern.js"; import { resolveStorageLocations } from "../locations.js"; import { insertEventOnce, insertEventStrict } from "./events-repository.js"; /** Informational `claim_holder` value stamped on attempt rows: the dispatching process. */ function dispatchingProcess() { return `pid:${process.pid}`; } /** * Repository owning every raw SQL statement against `workflow_runs` and * `workflow_run_steps`. It is DB-location-agnostic: the lifecycle helper * {@link withWorkflowRunsRepo} binds it to {@link StorageLocations.stateDb}, so * a future storage move changes only `locations.ts`. * * ## Connection-lifetime contract (WS5) * * Every read method fully materialises its result set (`.all()` / `.get()` into * plain values/arrays) before returning. The repository NEVER hands a live * statement iterator or cursor back across the {@link withWorkflowRunsRepo} * scope boundary, so the connection can be closed immediately after `fn` * resolves without truncating lazy iteration. */ export class WorkflowRunsRepository { db; constructor(db) { this.db = db; } /** Escape hatch for the transaction-bounded write paths still orchestrated * in runs.ts. The repository owns the SQL; the caller owns the transaction * boundary (unchanged from the pre-extraction `db.transaction(() => …)`). */ transaction(fn) { return this.db.transaction(fn)(); } immediateTransaction(fn) { return withImmediateTransaction(this.db, () => fn(this.db)); } // ── reads (fully materialised) ───────────────────────────────────────────── /** * The top-level start guard `publishWorkflowRunV4` uses to refuse starting * a SECOND active run of the same ref in the same scope. `AND * parent_run_id IS NULL` (B-N10's FOURTH site, code-review round 4 finding * 2 / Review log R2): a child run carries the PARENT's `scope_key` (P3a * §5.2), so once a parent publishes a child of some ref, starting a * TOP-LEVEL run of that same ref in that scope must never resolve to the * child — the child is not "already an active run in this scope" from a * fresh top-level invocation's point of view; the PARENT, if anything, is. * Before this filter, `publishWorkflowRunV4` refused with * `RESOURCE_ALREADY_EXISTS` naming the CHILD's own run id, instructing the * operator to `akm workflow abandon` a child a parent is actively driving. * For any database with no child rows the result is byte-identical, same * as the other three B-N10 sites below. * * Always scope-local: `scopeKey` is a real scope, never "every scope" — see * {@link findActiveRunOutsideScope} for the cross-scope warning's query. */ findActiveRunForScope(workflowRefs, scopeKey) { const refs = typeof workflowRefs === "string" ? [workflowRefs] : [...workflowRefs]; if (refs.length === 0) return undefined; return this.db .prepare(`SELECT id, current_step_id FROM workflow_runs WHERE workflow_ref IN (${refs.map(() => "?").join(", ")}) AND scope_key = ? AND status = 'active' AND parent_run_id IS NULL ORDER BY updated_at DESC, created_at DESC LIMIT 1`) .get(...refs, scopeKey); } /** * The cross-scope start warning's query (#942): the most recently active * run of these refs OUTSIDE `scopeKey` — i.e. `scope_key IS NULL OR * scope_key != scopeKey`, not merely "the most recent active run of these * refs anywhere". A same-scope active row must never win the `LIMIT 1` and * mask a DIFFERENT scope's run: with `--new`/`--force`, the caller's own * active run could otherwise sort first (most recently updated) and hide a * third scope's run entirely, so `startWorkflowRun` silently warned about * nothing while a genuinely stale run in another scope went unreported. * `findActiveRunForScope` deliberately stays scope-local and is never used * for this purpose. */ findActiveRunOutsideScope(workflowRefs, scopeKey) { const refs = typeof workflowRefs === "string" ? [workflowRefs] : [...workflowRefs]; if (refs.length === 0) return undefined; return (this.db .prepare(`SELECT * FROM workflow_runs WHERE workflow_ref IN (${refs.map(() => "?").join(", ")}) AND (scope_key IS NULL OR scope_key != ?) AND status = 'active' AND parent_run_id IS NULL ORDER BY updated_at DESC, created_at DESC LIMIT 1`) .get(...refs, scopeKey) ?? undefined); } getRunById(runId) { return (this.db.prepare("SELECT * FROM workflow_runs WHERE id = ?").get(runId) ?? undefined); } /** * The scope-attach lookup `akm workflow run <ref>` uses to find an * in-progress top-level run to resume instead of starting a new one. * `AND parent_run_id IS NULL` (B-N10): a child run must never be attached * to directly through this path — a parent-driven child would then have * TWO drivers. For any database with no child rows the result is * byte-identical. * * Always scope-local: `scopeKey` is a real scope, never "every scope" — see * {@link findActiveRunOutsideScope} for the cross-scope warning's query. */ getActiveRunRowForScope(workflowRefs, scopeKey) { const refs = typeof workflowRefs === "string" ? [workflowRefs] : [...workflowRefs]; if (refs.length === 0) return undefined; return (this.db .prepare(`SELECT * FROM workflow_runs WHERE workflow_ref IN (${refs.map(() => "?").join(", ")}) AND scope_key = ? AND status = 'active' AND parent_run_id IS NULL ORDER BY updated_at DESC, created_at DESC LIMIT 1`) .get(...refs, scopeKey) ?? undefined); } hasRun(runId) { const row = this.db.prepare("SELECT 1 FROM workflow_runs WHERE id = ? LIMIT 1").get(runId); return !!row; } /** The one run id starting with `prefix` (#919); `UsageError` on several, `NotFoundError` on none. */ resolveRunIdPrefix(prefix) { const escaped = escapeLikePattern(prefix); const rows = this.db .prepare("SELECT id FROM workflow_runs WHERE id LIKE ? ESCAPE '\\' ORDER BY id ASC") .all(`${escaped}%`); if (rows.length === 1) return rows[0].id; if (rows.length > 1) { throw new UsageError(`Ambiguous workflow run id prefix "${prefix}" — matches: ${rows.map((r) => r.id).join(", ")}`, "INVALID_FLAG_VALUE"); } throw new NotFoundError(`Workflow run "${prefix}" not found.`, "WORKFLOW_NOT_FOUND"); } listRuns(filter) { const filters = []; const params = []; // `scopeKey: null` (#942) is "every scope" — the predicate is omitted // rather than bound as SQL NULL (which would match nothing, since a real // `scope_key` column value is never NULL for a fresh run). if (filter.scopeKey !== null) { filters.push("scope_key = ?"); params.push(filter.scopeKey); } if (filter.workflowRef) { filters.push("workflow_ref = ?"); params.push(filter.workflowRef); } else if (filter.workflowRefs && filter.workflowRefs.length > 0) { filters.push(`workflow_ref IN (${filter.workflowRefs.map(() => "?").join(", ")})`); params.push(...filter.workflowRefs); } if (filter.activeOnly) { // `activeOnly` means EXACTLY status='active' — a run currently // executable. A `blocked` run is NOT active (it is parked awaiting a // human `resume`), so it must never appear under `--active`, or a script // treating `--active` output as executable work would pick up a blocked // run (owner manual-validation finding 1). Blocked runs stay visible in // plain `list` (all statuses) with their `blocked` status. The // active-OR-blocked scope semantics some call sites want (the `akm show` // scope guard, which surfaces a blocked run as the scope's occupant) live // in the SEPARATE {@link findActiveOrBlockedRunForScope} — never folded // into this shared list filter. filters.push("status = 'active'"); } // B-N10: a child run is invisible to this scope query unless the caller // explicitly opts in (`akm workflow list --children`). For any database // with no child rows the result set is byte-identical either way. if (!filter.includeChildren) { filters.push("parent_run_id IS NULL"); } const where = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : ""; return this.db .prepare(`SELECT * FROM workflow_runs ${where} ORDER BY updated_at DESC, created_at DESC`) .all(...params); } getStepsForRun(runId) { return this.db .prepare("SELECT * FROM workflow_run_steps WHERE run_id = ? ORDER BY sequence_index ASC") .all(runId); } getStep(runId, stepId) { return this.db.prepare("SELECT * FROM workflow_run_steps WHERE run_id = ? AND step_id = ?").get(runId, stepId); } /** * The `akm show` active-run guard: which run (if any) currently occupies * this scope. `AND parent_run_id IS NULL` (B-N10) — a child a parent is * driving must never be reported as "the" active run; the PARENT is. For * any database with no child rows the result is byte-identical. */ findActiveOrBlockedRunForScope(scopeKey) { return (this.db .prepare("SELECT id, current_step_id, workflow_ref FROM workflow_runs WHERE scope_key = ? AND status IN ('active', 'blocked') AND parent_run_id IS NULL ORDER BY updated_at DESC LIMIT 1") .get(scopeKey) ?? null); } // ── writes ───────────────────────────────────────────────────────────────── insertRun(input) { // R-R3 (P3a Review log; docs/plans/specs/p4-deletions-closeout.md §8): // this 12-column list is hand-duplicated by publishChildWorkflowRun's own // INSERT below, which extends it with parent_run_id/parent_unit_id/ // invocation_key. A signature refactor to share one INSERT builder was // considered and deliberately deferred — see // docs/architecture/decisions/0009-child-run-publication-column-parity.md. // Keep both column lists in sync by hand. this.db .prepare(`INSERT INTO workflow_runs ( id, workflow_ref, scope_key, workflow_entry_id, workflow_title, status, params_json, current_step_id, created_at, updated_at, agent_harness, agent_session_id ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`) .run(input.id, input.workflowRef, input.scopeKey, input.workflowEntryId, input.workflowTitle, input.paramsJson, input.currentStepId, input.createdAt, input.updatedAt, input.agentHarness, input.agentSessionId); } insertSteps(steps) { const insertStep = this.db.prepare(`INSERT INTO workflow_run_steps ( run_id, step_id, step_title, instructions, completion_json, sequence_index, status ) VALUES (?, ?, ?, ?, ?, ?, 'pending')`); for (const step of steps) { insertStep.run(step.runId, step.stepId, step.stepTitle, step.instructions, step.completionJson, step.sequenceIndex); } } reopenStepsForResume(runId, currentStepId) { this.db .prepare(`UPDATE workflow_run_steps SET status = 'pending', notes = NULL, evidence_json = NULL, completed_at = NULL WHERE run_id = ? AND step_id = ? AND status IN ('blocked', 'failed')`) .run(runId, currentStepId); } markRunActive(runId, updatedAt) { this.db.prepare("UPDATE workflow_runs SET status = 'active', updated_at = ? WHERE id = ?").run(updatedAt, runId); } updateStepCompletion(input) { this.db .prepare(`UPDATE workflow_run_steps SET status = ?, notes = ?, evidence_json = ?, summary = ?, completed_at = ? WHERE run_id = ? AND step_id = ?`) .run(input.status, input.notes, input.evidenceJson, input.summary, input.completedAt, input.runId, input.stepId); } updateRunState(input) { this.db .prepare(`UPDATE workflow_runs SET status = ?, current_step_id = ?, updated_at = ?, completed_at = ? WHERE id = ?`) .run(input.status, input.currentStepId, input.updatedAt, input.completedAt, input.runId); } markRunAbandoned(runId, updatedAt) { const result = this.db .prepare(`UPDATE workflow_runs SET status = 'failed', updated_at = ?, completed_at = ? WHERE id = ? AND status IN ('active', 'blocked')`) .run(updatedAt, updatedAt, runId); return Number(result.changes) === 1; } /** * Atomically publish the entire run spine. No run row, partial spine, plan * attachment, or started event can escape independently across a crash or * statement failure. */ publishWorkflowRunV4(input) { this.immediateTransaction((db) => { if (!input.force) { // The uniqueness guard must never silently skip its scope predicate // (#942) — every real caller (`startWorkflowRun`) stamps a concrete // scope key, so a null one here means the caller is misusing this // top-level guard, not that scoping should be waived. if (input.run.scopeKey === null) { throw new Error("publishWorkflowRunV4: run.scopeKey must not be null for the scope uniqueness guard."); } const existing = this.findActiveRunForScope(input.workflowRefs, input.run.scopeKey); if (existing) { throw new UsageError(`Workflow ${input.run.workflowRef} already has an active run in this scope ` + `(id=${existing.id}, scope=${input.run.scopeKey}, step=${existing.current_step_id ?? "—"}). ` + `Use 'akm workflow run ${input.run.workflowRef}' to resume it or ` + `'akm workflow abandon ${existing.id}' to give up on it.`, "RESOURCE_ALREADY_EXISTS"); } } this.insertRun(input.run); this.insertSteps(input.steps); db.prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = ? WHERE id = ?").run(input.planJson, input.planHash, WORKFLOW_PLAN_VERSION, input.run.id); insertEventOnce(db, { eventType: "workflow_started", ts: input.run.createdAt, ref: input.run.workflowRef, metadata: { runId: input.run.id, status: "active" }, idempotencyKey: input.run.id, idempotencyMetadataKey: "runId", }); }); } // ── child workflow run publication (migration 023, P3a §5.2-§5.3) ───────── // // No production caller exists in P3a (§5.5) — dispatch is P3b's. These // three methods are reachable only from tests until then. /** * Idempotently publish a child workflow run underneath the parent unit * that spawned it. ONE `immediateTransaction`: SELECT by * `(parent_run_id, invocation_key)` and return the existing child if * present; otherwise INSERT the child run row (parentage columns + * `invocation_key`), its step rows, attach the embedded frozen child plan * (`plan_ir_version` = the current plan version), and append its * `workflow_started` event — then return the freshly-inserted row. * * Deliberately does NOT call {@link findActiveRunForScope} or raise * `RESOURCE_ALREADY_EXISTS` (top-level scope-conflict rules do not apply to * a child), and reads no source: the child plan was frozen into the parent's. * * This method's serialization guarantee holds only when it is the * OUTERMOST transaction on the connection (spec Review log R10, * docs/plans/specs/p3a-plan-v5-child-freeze.md). `withImmediateTransaction` * (src/core/state-db.ts) has a re-entrancy guard: if a transaction is * already open on the connection, it SILENTLY JOINS that transaction * instead of issuing its own `BEGIN IMMEDIATE`. * `WorkflowRunsRepository.transaction()` is DEFERRED (`db.transaction(fn)()`) * and is already used in production at `resumeWorkflowRun` * (src/workflows/runtime/runs.ts:506) and `completeWorkflowStep` (:782) — * a caller that wires this call inside one of those outer transactions * loses the guarantee below: the SELECT can read a stale snapshot, both * publishers can miss the existing row, and the loser's INSERT hits * `idx_workflow_runs_invocation_key` with a raw `SQLiteError` instead of * returning the winner's row. * * As the outermost transaction, the whole SELECT-else-INSERT sequence runs * inside one `BEGIN IMMEDIATE`, so two concurrent callers racing on the * same `(parentRunId, invocationKey)` serialize on SQLite's write lock: the * first to acquire it inserts and commits, and the second's own SELECT — * which can only run once it has acquired the lock in turn — finds and * returns the first's row rather than inserting a duplicate or throwing * (C-09). Calling this twice with the same key, including across a crash * between publish and parent-side recording, is therefore safe and returns * the same child both times, with exactly one event and one step set * (C-08). */ publishChildWorkflowRun(input) { return this.immediateTransaction((db) => { const existing = db .prepare("SELECT * FROM workflow_runs WHERE parent_run_id = ? AND invocation_key = ?") .get(input.parentRunId, input.invocationKey); if (existing) return existing; // R-R3 (P3a Review log; docs/plans/specs/p4-deletions-closeout.md §8): // the first 12 columns here must stay byte-identical to insertRun's own // column list above (hand-duplicated, not shared, by deliberate choice // — see docs/architecture/decisions/0009-child-run-publication-column-parity.md). // Keep both column lists in sync by hand. db.prepare(`INSERT INTO workflow_runs ( id, workflow_ref, scope_key, workflow_entry_id, workflow_title, status, params_json, current_step_id, created_at, updated_at, agent_harness, agent_session_id, parent_run_id, parent_unit_id, invocation_key ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.run.id, input.run.workflowRef, input.run.scopeKey, input.run.workflowEntryId, input.run.workflowTitle, input.run.paramsJson, input.run.currentStepId, input.run.createdAt, input.run.updatedAt, input.run.agentHarness, input.run.agentSessionId, input.parentRunId, input.spawnedByUnitId, input.invocationKey); this.insertSteps(input.steps); db.prepare("UPDATE workflow_runs SET plan_json = ?, plan_hash = ?, plan_ir_version = ? WHERE id = ?").run(input.planJson, input.planHash, WORKFLOW_PLAN_VERSION, input.run.id); insertEventOnce(db, { eventType: "workflow_started", ts: input.run.createdAt, ref: input.run.workflowRef, metadata: { runId: input.run.id, status: "active" }, idempotencyKey: input.run.id, idempotencyMetadataKey: "runId", }); return db.prepare("SELECT * FROM workflow_runs WHERE id = ?").get(input.run.id); }); } /** * Persist a run's resolved declared `outputs:` (migration 024, P3b §4.3). * Called from INSIDE `completeWorkflowStep`'s own write transaction — this * method opens none of its own, matching `updateStepCompletion` / * `updateRunState` immediately above. */ setRunOutputs(runId, outputsJson) { this.db.prepare("UPDATE workflow_runs SET outputs_json = ? WHERE id = ?").run(outputsJson, runId); } /** Every child run published under `parentRunId`, oldest first. `[]` when none. */ childRunsOf(parentRunId) { return this.db .prepare("SELECT * FROM workflow_runs WHERE parent_run_id = ? ORDER BY created_at ASC, id ASC") .all(parentRunId); } /** The child run published under `(parentRunId, key)`, or undefined if none has been published yet. */ getRunByInvocationKey(parentRunId, key) { return (this.db .prepare("SELECT * FROM workflow_runs WHERE parent_run_id = ? AND invocation_key = ?") .get(parentRunId, key) ?? undefined); } // ── durable v4 append-only dispatch attempts (migration 022) ───────────── getUnitAttempts(runId, unitId) { return this.db .prepare(`SELECT * FROM workflow_run_unit_attempts WHERE run_id = ? AND unit_id = ? ORDER BY attempt`) .all(runId, unitId); } getAttemptAccounting(runId) { const row = this.db .prepare(`SELECT COUNT(*) AS total_attempts, COALESCE(SUM(tokens), 0) AS total_tokens, COALESCE(SUM(CASE WHEN phase = 'unit' THEN 1 ELSE 0 END), 0) AS dispatch_attempts, COALESCE(SUM(CASE WHEN phase = 'unit' THEN COALESCE(tokens, 0) ELSE 0 END), 0) AS dispatch_tokens, COALESCE(SUM(CASE WHEN phase = 'gate' THEN 1 ELSE 0 END), 0) AS gate_attempts, COALESCE(SUM(CASE WHEN phase = 'gate' THEN COALESCE(tokens, 0) ELSE 0 END), 0) AS gate_tokens FROM workflow_run_unit_attempts WHERE run_id = ?`) .get(runId); return { totalAttempts: Number(row.total_attempts), totalTokens: Number(row.total_tokens), dispatchAttempts: Number(row.dispatch_attempts), dispatchTokens: Number(row.dispatch_tokens), gateAttempts: Number(row.gate_attempts), gateTokens: Number(row.gate_tokens), }; } /** * Reserve or reclaim one dispatch attempt. The attempt row, the unit * projection, and the directly-paired started event share one IMMEDIATE * transaction. A latest attempt still `running` is reclaimed in place — * same attempt number, same stable dispatch id, no duplicate start event — * so a re-dispatch after a crash stays explicitly at-least-once with an * idempotency key a downstream can dedupe on. One process drives a run at a * time (the per-run lock file in `workflows/exec/run-workflow.ts`), so a * `running` attempt found here is never another live driver's. */ reserveUnitAttempt(input) { const holder = dispatchingProcess(); return this.immediateTransaction((db) => { const run = db.prepare("SELECT workflow_ref, status FROM workflow_runs WHERE id = ?").get(input.runId); if (!run || run.status !== "active") { throw new UsageError(`Workflow run ${input.runId} is not active; refusing to reserve a durable dispatch attempt.`, "RESOURCE_ALREADY_EXISTS"); } const latest = db .prepare(`SELECT * FROM workflow_run_unit_attempts WHERE run_id = ? AND unit_id = ? ORDER BY attempt DESC LIMIT 1`) .get(input.runId, input.unitId); if (latest?.status === "running") return { kind: "reclaimed", attempt: latest }; const attemptNumber = (latest?.attempt ?? 0) + 1; const dispatchId = randomUUID(); db.prepare(`INSERT INTO workflow_run_unit_attempts ( run_id, unit_id, attempt, dispatch_id, step_id, node_id, phase, runner, engine, model, input_hash, status, worktree_path, started_at, claim_holder, claim_expires_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?)`).run(input.runId, input.unitId, attemptNumber, dispatchId, input.stepId, input.nodeId, input.phase, input.runner, input.engine, input.model, input.inputHash, input.worktreePath ?? null, input.now, holder, input.now); db.prepare(`INSERT INTO workflow_run_units ( run_id, unit_id, step_id, node_id, parent_unit_id, phase, runner, engine, model, status, input_hash, worktree_path, started_at, claim_holder, claim_expires_at, attempts ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id, unit_id) DO UPDATE SET step_id = excluded.step_id, node_id = excluded.node_id, parent_unit_id = excluded.parent_unit_id, phase = excluded.phase, runner = excluded.runner, engine = excluded.engine, model = excluded.model, status = 'running', input_hash = excluded.input_hash, worktree_path = excluded.worktree_path, started_at = excluded.started_at, claim_holder = excluded.claim_holder, claim_expires_at = excluded.claim_expires_at, result_json = NULL, tokens = NULL, failure_reason = NULL, session_id = NULL, finished_at = NULL, attempts = excluded.attempts`).run(input.runId, input.unitId, input.stepId, input.nodeId, input.parentUnitId ?? null, input.phase, input.runner, input.engine, input.model, input.inputHash, input.worktreePath ?? null, input.now, holder, input.now, attemptNumber); insertEventStrict(db, { eventType: "workflow_unit_started", ts: input.now, ref: run.workflow_ref, metadata: { runId: input.runId, stepId: input.stepId, unitId: input.unitId, attempt: attemptNumber, dispatchId, phase: input.phase, status: "running", }, }); const attempt = db .prepare(`SELECT * FROM workflow_run_unit_attempts WHERE run_id = ? AND unit_id = ? AND attempt = ?`) .get(input.runId, input.unitId, attemptNumber); return { kind: "reserved", attempt }; }); } /** * Commit one terminal result, known usage, and finish event. Returns false * when the attempt is no longer `running` (already finished) — a duplicate * terminal callback never adds usage or a second event. */ finishUnitAttempt(input) { return this.immediateTransaction((db) => { const changed = db .prepare(`UPDATE workflow_run_unit_attempts SET status = ?, result_json = ?, tokens = ?, failure_reason = ?, session_id = ?, finished_at = ? WHERE run_id = ? AND unit_id = ? AND attempt = ? AND dispatch_id = ? AND status = 'running'`) .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId, input.attempt, input.dispatchId); if (Number(changed.changes) !== 1) return false; const attempt = db .prepare(`SELECT * FROM workflow_run_unit_attempts WHERE run_id = ? AND unit_id = ? AND attempt = ?`) .get(input.runId, input.unitId, input.attempt); const projection = db .prepare(`UPDATE workflow_run_units SET status = ?, result_json = ?, tokens = ?, failure_reason = ?, session_id = ?, finished_at = ? WHERE run_id = ? AND unit_id = ? AND status = 'running' AND attempts = ?`) .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId, input.attempt); if (Number(projection.changes) !== 1) { throw new Error(`Durable attempt ${input.unitId} has no matching live workflow_run_units projection.`); } const run = db.prepare("SELECT workflow_ref FROM workflow_runs WHERE id = ?").get(input.runId); if (!run) throw new Error(`Durable attempt ${input.unitId} has no owning workflow run.`); insertEventStrict(db, { eventType: "workflow_unit_finished", ts: input.finishedAt, ref: run.workflow_ref, metadata: { runId: input.runId, stepId: attempt.step_id, unitId: input.unitId, attempt: input.attempt, dispatchId: input.dispatchId, phase: attempt.phase, status: input.status, ...(input.failureReason ? { failureReason: input.failureReason } : {}), ...(input.tokens !== null ? { tokens: input.tokens } : {}), }, }); return true; }); } // ── current unit status projection ───────────────────────────────────────── // // Writes to `workflow_run_units` should go through the serialized writer // queue (`src/workflows/exec/unit-writer.ts`) when N units may complete // concurrently — SQLite has a single writer per database FILE, and outside a // {@link withWorkflowRunsConnection} scope `withWorkflowRunsRepo` opens a // fresh connection per call (so N concurrent writers would contend against // each other for the write lock). getUnitsForRun(runId) { return this.db .prepare("SELECT * FROM workflow_run_units WHERE run_id = ? ORDER BY started_at ASC, unit_id ASC") .all(runId); } getUnitsForStep(runId, stepId) { return this.db .prepare("SELECT * FROM workflow_run_units WHERE run_id = ? AND step_id = ? ORDER BY started_at ASC, unit_id ASC") .all(runId, stepId); } /** One unit row by primary key, or undefined. */ getUnit(runId, unitId) { return this.db.prepare("SELECT * FROM workflow_run_units WHERE run_id = ? AND unit_id = ?").get(runId, unitId); } } /** * Run `fn` against a {@link WorkflowRunsRepository} bound to state.db * ({@link StorageLocations.stateDb}, the post-cutover home of the * `workflow_runs` / `workflow_run_steps` / `workflow_run_units` tables). * * Connection lifetime — BORROW-OR-OWN (mirrors `withStateDb`'s `borrowed` * option and `appendEvent`'s `ctx.db` seam): * * - Inside a {@link withWorkflowRunsConnection} scope, the ambient handle is * BORROWED and left open for the rest of the scope. A wide `map` fan-out * therefore opens ONE connection for the whole step instead of two per unit * (insert + finish) — `openStateDatabase` opens a read-only ledger-preflight * handle on every call, so the per-call cost is milliseconds, not microseconds. * - Outside a scope the behaviour is unchanged: open a fresh connection, run * `fn`, close it in a `finally`. * * Repository read methods fully materialise their results, so closing an owned * handle here never truncates lazy iteration (WS5 connection-lifetime rule). * The signature and semantics are identical in both modes — reuse is purely an * internal optimisation and no caller needs to know which mode it is in. */ export async function withWorkflowRunsRepo(fn) { const stateDb = resolveStorageLocations().stateDb; const borrowed = borrowScopedStateDb(stateDb); if (borrowed) return await Promise.resolve(fn(new WorkflowRunsRepository(borrowed))); const db = openStateDatabase(stateDb); try { return await Promise.resolve(fn(new WorkflowRunsRepository(db))); } finally { db.close(); } } /** * Run `fn` with ONE state.db connection shared by every `withWorkflowRunsRepo` * call (and every {@link import("../../core/events.js").appendEvent}) inside its * async extent. The handle opens on first use and closes when `fn` settles; * nesting joins the outer scope. * * Correctness under concurrency: `bun:sqlite` statements and * `withImmediateTransaction` bodies run synchronously to completion, so * logically concurrent units cannot interleave statements on the shared handle * in a single-threaded event loop — sharing REMOVES in-process writer * contention instead of creating it. Cross-process arbitration (WAL, * `busy_timeout`, the per-run lock file) is untouched. See `core/state-db-scope.ts` for * the escaped-async-work guard. */ export function withWorkflowRunsConnection(fn) { return withStateDbScope(fn, { path: resolveStorageLocations().stateDb }); }