diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5fc5d..846ea90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to Agrippa are documented here. The format follows ## [Unreleased] +### Added + +- **A follow-up can publish what it was steered to produce** (ADR-0019). When the base flow delivers to a branch, a follow-up that changed the workspace pauses at its own approval presenting the full cumulative patch, then pushes exactly one deterministic commit on top of what the chain last published (an expected-tip CAS — `runs.published_sha` is the chain's record) and re-targets the same PR. A steer that changed nothing publishes nothing; a byte-identical patch carries its earlier approval forward instead of re-asking; and a branch that no longer matches the chain's record — a human push, a post-merge deletion — fails typed as `publish_conflict` with the remote untouched. +- **Central runs route to the host that holds their workspace** (the per-host queue, ADR-0018 amendment). Hosts are identified by their storage (`WORKSPACE_ROOT/.agrippa-host-id`); repo checkouts stamp `runs.workspace_host` first-writer-wins; follow-ups inherit it; producers send host-pinned jobs to `run.host.`, which only workers mounting that storage poll — follow-ups and resumes land where the directory lives instead of bouncing off claim-time declines, which remain as the deploy-skew fallback. A dead pin fails typed rather than circulating: runs parked on a host no live worker has advertised for five minutes fail `workspace_lost` with a notification (the `dead-host-runs` sweeper stage). + ### Fixed - A Codex resume whose session is gone (collected or migrated session home — the CLI dies before `thread.started` with "no rollout found") now reports the resume **rejected**, so the engine runs its context-loss disclosure and continues fresh, instead of failing the step `model_error` and re-offering the dead session on every retry. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8628510..f493c17 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { createDb, migrateDb, seed } from "@agrippa/db"; import { createRunQueue, - dbRunExecutorResolver, + dbRunQueueResolver, RedisEventBus, seedBuiltinTemplates, } from "@agrippa/orchestration"; @@ -20,7 +20,7 @@ if (process.env.AGRIPPA_MIGRATE_ON_BOOT !== "0") { } const queue = await createRunQueue(process.env.DATABASE_URL as string, { - resolveRunExecutors: dbRunExecutorResolver(db), + resolveRunQueue: dbRunQueueResolver(db), }); const bus = process.env.REDIS_URL ? new RedisEventBus(process.env.REDIS_URL) : null; if (!bus) console.warn("[api] REDIS_URL not set — SSE falls back to DB polling"); diff --git a/apps/worker/src/consumer.integration.test.ts b/apps/worker/src/consumer.integration.test.ts index 18ffb51..ab75faa 100644 --- a/apps/worker/src/consumer.integration.test.ts +++ b/apps/worker/src/consumer.integration.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it } from "bun:test"; -import { runExecuteQueueName, runExecuteSubsetQueues } from "@agrippa/core"; +import { runExecuteQueueName, runExecuteSubsetQueues, runHostQueueName } from "@agrippa/core"; import { createDb } from "@agrippa/db"; import { type BossQueue, createRunQueue } from "@agrippa/orchestration"; import { sql } from "drizzle-orm"; @@ -60,9 +60,17 @@ describe.skipIf(!dbUp)("executor-set queue routing + fetch loop", () => { await queue?.stop(); }); + // name-level routes (host queues) win over executor-id derivation — the + // same precedence dbRunQueueResolver gives a stamped workspace_host + const routes = new Map(); const setup = async (): Promise => { queue ??= await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: async (runId) => resolvers.get(runId) ?? [], + resolveRunQueue: async (runId) => { + const named = routes.get(runId); + if (named) return named; + const ids = resolvers.get(runId); + return ids && ids.length > 0 ? runExecuteQueueName(ids) : null; + }, }); return queue; }; @@ -81,7 +89,7 @@ describe.skipIf(!dbUp)("executor-set queue routing + fetch loop", () => { const runId = Bun.randomUUIDv7(); // no resolver entry → [] // post-M2-flush nothing consumes `run.execute` — parking the job there // would strand it silently, so the enqueue must throw instead - await expect(q.enqueueRun(runId)).rejects.toThrow(/cannot derive an executor set/); + await expect(q.enqueueRun(runId)).rejects.toThrow(/cannot derive a queue/); expect(await jobRow(runId)).toBeUndefined(); }); @@ -130,6 +138,51 @@ describe.skipIf(!dbUp)("executor-set queue routing + fetch loop", () => { expect(deliveries).toBe(1); }); + it("a host-pinned run lands only on the worker mounting that storage", async () => { + // the two-root fleet, at the fetch level (ADR-0018 amendment): the holder + // polls its own run.host. queue; a peer with the SAME executor set + // does not, so it can never fetch a chain whose directory it lacks + const q = await setup(); + const execH = `exec-h-${suite}`; + const hostQueue = runHostQueueName(`host-fleet-${suite}`); + for (const name of [...runExecuteSubsetQueues([execH]), hostQueue]) { + await q.boss.createQueue(name); + } + const seenByHolder: string[] = []; + const seenByPeer: string[] = []; + loops.push( + startRunFetchLoop({ + boss: q.boss, + queues: [...runExecuteSubsetQueues([execH]), hostQueue], + slots: 2, + handler: async (job) => void seenByHolder.push(job.data.runId), + logger: noopLogger, + tickMs: 100, + }), + startRunFetchLoop({ + boss: q.boss, + queues: runExecuteSubsetQueues([execH]), + slots: 2, + handler: async (job) => void seenByPeer.push(job.data.runId), + logger: noopLogger, + tickMs: 100, + }), + ); + + const pinned = Bun.randomUUIDv7(); + routes.set(pinned, hostQueue); + await q.enqueueRun(pinned); + await waitFor(() => seenByHolder.includes(pinned)); + expect(seenByPeer).not.toContain(pinned); + await waitFor(async () => (await jobRow(pinned))?.state === "completed"); + + // an unpinned run of the same executor set stays fair game for either + const unpinned = Bun.randomUUIDv7(); + resolvers.set(unpinned, [execH]); + await q.enqueueRun(unpinned); + await waitFor(() => seenByHolder.includes(unpinned) || seenByPeer.includes(unpinned)); + }); + it("updateQueues extends a live loop to a daemon-covered set queue", async () => { const q = await setup(); // fresh executor ids so the loops from earlier tests cannot consume these diff --git a/apps/worker/src/consumer.ts b/apps/worker/src/consumer.ts index 3ce90e6..dc17e01 100644 --- a/apps/worker/src/consumer.ts +++ b/apps/worker/src/consumer.ts @@ -215,7 +215,16 @@ export function createRunConsumer(db: Db, deps: EngineDeps, queue: BossQueue): R err instanceof WorkspaceBusyError ) { const [run] = await db.select({ status: runs.status }).from(runs).where(eq(runs.id, runId)); - if (run && (run.status === "queued" || run.status === "waiting_approval")) { + if ( + run && + (run.status === "queued" || + run.status === "waiting_approval" || + // a crashed run's pg-boss retry delivered to the wrong host: the + // pinned-elsewhere decline completes the job, and the LEASE + // sweeper re-enqueues the leaseless running run through the + // resolver — onto the host queue this delivery bypassed + (run.status === "running" && err instanceof WorkspaceElsewhereError)) + ) { deps.logger.warn(`run ${runId}: declining — ${String((err as Error).message)}`); // one timeline event so the SPA can show WHY the run is waiting await appendRunEvent(db, { diff --git a/apps/worker/src/deps/scm.ts b/apps/worker/src/deps/scm.ts index 56e369f..0957abd 100644 --- a/apps/worker/src/deps/scm.ts +++ b/apps/worker/src/deps/scm.ts @@ -6,8 +6,13 @@ import { type ScmService, workspaceKeyOf, } from "@agrippa/orchestration"; -import { applyApprovedPatch, platformGitDirFor, workspaceIntact } from "@agrippa/workspace"; -import { and, desc, eq } from "drizzle-orm"; +import { + applyApprovedPatch, + platformGitDirFor, + TipConflictError, + workspaceIntact, +} from "@agrippa/workspace"; +import { and, desc, eq, isNotNull } from "drizzle-orm"; import { credentialedUrl, git, @@ -145,15 +150,38 @@ export class GitScmService implements ScmService { baseSha = pinnedBase; } - const { commitSha } = await applyApprovedPatch({ - fetchSource, - fetchRef, - baseSha, - branch: spec.branch, - patch, - pushUrl, - }); - return { status: "pushed", commitSha }; + // The chain's expected tip E (ADR-0019): the latest snapshot any run of + // this workspace chain published. Absent → first publish (creation CAS); + // present → the new commit parents on it and the push advances it. + const expectedTip = await this.chainExpectedTip(key); + try { + const { commitSha } = await applyApprovedPatch({ + fetchSource, + fetchRef, + baseSha, + branch: spec.branch, + patch, + pushUrl, + ...(expectedTip === undefined ? {} : { expectedTip }), + }); + return { status: "pushed", commitSha }; + } catch (err) { + if (err instanceof TipConflictError) { + return { status: "tip_conflict", observedTip: err.observedTip }; + } + throw err; + } + } + + /** E = the latest non-null published_sha across the workspace chain. */ + private async chainExpectedTip(workspaceKey: string): Promise { + const [row] = await this.db + .select({ publishedSha: runs.publishedSha }) + .from(runs) + .where(and(eq(runs.workspaceKey, workspaceKey), isNotNull(runs.publishedSha))) + .orderBy(desc(runs.number)) + .limit(1); + return row?.publishedSha ?? undefined; } /** The base the SERVER pinned at checkout (runs.workspace_ref, remote runs). */ diff --git a/apps/worker/src/deps/workspace.test.ts b/apps/worker/src/deps/workspace.test.ts index 733fdb9..1e192c3 100644 --- a/apps/worker/src/deps/workspace.test.ts +++ b/apps/worker/src/deps/workspace.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { appendFile, chmod, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { runExecuteQueueName, runHostQueueName } from "@agrippa/core"; import { createDb, encryptSecret, @@ -20,7 +21,7 @@ import { taskTypes, users, } from "@agrippa/db"; -import { seedBuiltinTemplates } from "@agrippa/orchestration"; +import { dbRunQueueResolver, seedBuiltinTemplates } from "@agrippa/orchestration"; import { eq, sql } from "drizzle-orm"; // WORKSPACE_ROOT is read at module load — point it at a scratch dir BEFORE @@ -252,6 +253,39 @@ describe.skipIf(!dbUp)("GitWorkspaceManager + GitScmService (real git)", () => { expect(await hostOf(pinnedRunId)).toBeNull(); }); + it("dbRunQueueResolver: a host pin routes to the host queue; a runtime pin never does", async () => { + const resolve = dbRunQueueResolver(db); + + const hostPinned = await newRunRow(); + await db.update(runs).set({ workspaceHost: "host-q" }).where(eq(runs.id, hostPinned)); + expect(await resolve(hostPinned)).toBe(runHostQueueName("host-q")); + + const plain = await newRunRow(); + expect(await resolve(plain)).toBe(runExecuteQueueName(["fake"])); + + // a daemon-pinned run's affinity IS its runtime pin — it routes by + // executor set even if a workspace host somehow got stamped + const [runtime] = await db + .insert(runtimes) + .values({ + orgId, + name: "route-guard", + tokenHash: "h", + tokenPrefix: `agrd_${Bun.randomUUIDv7().slice(-7)}`, + executors: [], + createdBy: userId, + }) + .returning({ id: runtimes.id }); + const daemonPinned = await newRunRow(); + await db + .update(runs) + .set({ runtimeId: runtime?.id as string, workspaceHost: "host-q" }) + .where(eq(runs.id, daemonPinned)); + expect(await resolve(daemonPinned)).toBe(runExecuteQueueName(["fake"])); + + expect(await resolve(Bun.randomUUIDv7())).toBeNull(); + }); + it("records the clone base and keeps sanitization out of the diff", async () => { const dir = workspaceDirFor(workspaceKey); const base = await platformBaseSha(workspaceKey); @@ -347,6 +381,82 @@ describe.skipIf(!dbUp)("GitWorkspaceManager + GitScmService (real git)", () => { expect(await workspace.diff(runId)).toContain("committed line"); }); + it("a follow-up publish advances the chain tip; a human advance conflicts typed (ADR-0019)", async () => { + const branch = publishBranch; + const dir = workspaceDirFor(workspaceKey); + // the ancestor's publication record, as the engine's git.push handler writes it + const tip1 = (await git(["rev-parse", branch], sourceDir)).trim(); + await db.update(runs).set({ publishedSha: tip1 }).where(eq(runs.id, runId)); + + const followupRow = async (): Promise => { + runNumber += 1; + const [row] = await db + .insert(runs) + .values({ + ...newRunIdentity(), + workspaceKey, // the CHAIN's directory, inherited like the API does + kind: "followup", + parentRunId: runId, + taskId, + projectId, + number: runNumber, + templateVersionId, + faberId, + executorId: "fake", + paramsSnapshot: {}, + modelResolution: {}, + createdBy: userId, + }) + .returning(); + return row?.id as string; + }; + + // the steer adds one more thing; evidence stays cumulative against the base + const followupId = await followupRow(); + await Bun.write(path.join(dir, "steer-addition.txt"), "asked for one more thing\n"); + const approved = await workspace.diff(followupId); + const advanced = await scm.push(followupId, { + projectId, + repo: { repoConnectionId }, + branch, + expectedPatch: approved, + }); + if (advanced.status !== "pushed") throw new Error(`expected pushed, got ${advanced.status}`); + // exactly one new commit, parented on what the chain last published + expect((await git(["rev-parse", `${branch}^`], sourceDir)).trim()).toBe(tip1); + expect((await git(["rev-list", "--count", `main..${branch}`], sourceDir)).trim()).toBe("2"); + // cumulative: the ancestor's work rides along with the steer's + const show = (spec: string) => + Bun.spawnSync(["git", "show", spec], { cwd: sourceDir, stdout: "pipe", stderr: "pipe" }); + expect(show(`${branch}:left-uncommitted.txt`).exitCode).toBe(0); + expect(show(`${branch}:steer-addition.txt`).exitCode).toBe(0); + await db.update(runs).set({ publishedSha: advanced.commitSha }).where(eq(runs.id, followupId)); + + // a human advances the branch; the next steer's publish must refuse typed + const human = ( + await gitIn(sourceDir, [ + "commit-tree", + `${advanced.commitSha}^{tree}`, + "-p", + advanced.commitSha, + "-m", + "human touch-up", + ]) + ).trim(); + await git(["update-ref", `refs/heads/${branch}`, human], sourceDir); + + const conflictedId = await followupRow(); + await Bun.write(path.join(dir, "another-steer.txt"), "and one more\n"); + const conflicted = await scm.push(conflictedId, { + projectId, + repo: { repoConnectionId }, + branch, + expectedPatch: await workspace.diff(conflictedId), + }); + expect(conflicted).toEqual({ status: "tip_conflict", observedTip: human }); + expect((await git(["rev-parse", branch], sourceDir)).trim()).toBe(human); + }); + it("refuses to publish a run with no commits and no changes", async () => { const emptyRunId = await newRunRow(); await workspace.checkout(emptyRunId, { diff --git a/apps/worker/src/heterogeneous-fleet.integration.test.ts b/apps/worker/src/heterogeneous-fleet.integration.test.ts index 256eed5..b848818 100644 --- a/apps/worker/src/heterogeneous-fleet.integration.test.ts +++ b/apps/worker/src/heterogeneous-fleet.integration.test.ts @@ -24,7 +24,7 @@ import { type BossQueue, buildParamsValidator, createRunQueue, - dbRunExecutorResolver, + dbRunQueueResolver, type EngineDeps, FakeResourceMaterializer, FakeWorkspaceManager, @@ -179,7 +179,7 @@ describe.skipIf(!dbUp)("heterogeneous fleet routing (Phase A verify)", () => { defaultFaberId = taskType?.defaultFaberId as string; queue = await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: dbRunExecutorResolver(db), + resolveRunQueue: dbRunQueueResolver(db), }); const workerA = createRunConsumer(db, depsFor({ fake: fakeOnA }), queue); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index f37f075..24e3f1a 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -21,7 +21,7 @@ import { collectExpiredWorkspaces, createRunQueue, DiskArtifactStore, - dbRunExecutorResolver, + dbRunQueueResolver, decideCheckpoint, type EngineDeps, enqueueAfterCommit, @@ -34,6 +34,7 @@ import { RedisEventBus, reconcileScheduleCalendar, type ScheduleFireOutcome, + sweepDeadHostRuns, sweepNotificationDeliveries, sweepOfflineRuntimes, sweepRunLeases, @@ -135,7 +136,7 @@ const bus = process.env.REDIS_URL ? new RedisEventBus(process.env.REDIS_URL) : new InProcessEventBus(); const queue = await createRunQueue(process.env.DATABASE_URL as string, { - resolveRunExecutors: dbRunExecutorResolver(db), + resolveRunQueue: dbRunQueueResolver(db), }); const deps: EngineDeps = { @@ -153,6 +154,8 @@ const deps: EngineDeps = { }, // execution-lease identity: claims, renewals, and releases all key on it lease: { owner: containerId }, + // claim-side half of the per-host queue: a run pinned elsewhere declines + workspaceHost, }; const SLOTS = Number(process.env.WORKER_SLOTS ?? 2); @@ -185,6 +188,7 @@ async function computeRunQueues(): Promise { const liveWorkers = await liveCentralWorkerSets(db); const list = selectRunQueues({ localExecutorIds: Object.keys(executors), + ownWorkspaceHost: workspaceHost, centralWorkerSets: liveWorkers.map((ads) => ads.map((e) => e.id)), runtimeAds: liveRuntimes.map((r) => ({ name: r.name, @@ -502,6 +506,16 @@ setInterval(async () => { // runs paused on an approval that has since been decided but whose resume // enqueue was lost (e.g. the API/worker died between the decision and the // send) — re-enqueue so the decision actually takes effect + // the dead-pin rule made mechanical (ADR-0018 amendment): a run parked on + // a host queue no live-and-ready worker serves fails workspace_lost, + // typed, instead of waiting forever on a directory that is gone + await stage("dead-host-runs", async () => { + for (const runId of await sweepDeadHostRuns(db)) { + deps.logger.warn(`run ${runId}: workspace host dead past grace — failed workspace_lost`); + await consumer.syncNotificationsBestEffort(runId); + } + }); + await stage("stranded-checkpoints", async () => { for (const runId of await findStrandedCheckpointRuns(db)) { await enqueueAfterCommit(() => queue.enqueueRun(runId), `stranded ${runId}`); diff --git a/apps/worker/src/run-queues.test.ts b/apps/worker/src/run-queues.test.ts index fa00e9d..4402b9b 100644 --- a/apps/worker/src/run-queues.test.ts +++ b/apps/worker/src/run-queues.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { runExecuteQueueName } from "@agrippa/core"; +import { runExecuteQueueName, runHostQueueName } from "@agrippa/core"; import { selectRunQueues } from "./run-queues"; const collect = (): { warnings: string[]; logger: { warn: (m: string) => void } } => { @@ -92,6 +92,28 @@ describe("selectRunQueues (dynamic coverage, codex round-2)", () => { expect(warnings.some((w) => w.includes("skipping its queues"))).toBe(true); }); + it("polls its own host queue, never a peer's, and none without an identity", () => { + const { logger } = collect(); + const withHost = selectRunQueues({ + localExecutorIds: ["fake"], + ownWorkspaceHost: "host-a", + centralWorkerSets: [], + runtimeAds: [], + logger, + }); + expect(withHost).toContain(runHostQueueName("host-a")); + // exactly one host queue — a worker never fetches a peer's pinned runs + expect(withHost.filter((n) => n.startsWith("run.host."))).toHaveLength(1); + + const withoutHost = selectRunQueues({ + localExecutorIds: ["fake"], + centralWorkerSets: [], + runtimeAds: [], + logger, + }); + expect(withoutHost.some((n) => n.startsWith("run.host."))).toBe(false); + }); + it("hard-caps the total queue set and logs", () => { const { warnings, logger } = collect(); // 8-id ads expand to 255 subsets each; 6 distinct ads overshoot 1024 diff --git a/apps/worker/src/run-queues.ts b/apps/worker/src/run-queues.ts index d1d0d9e..a0cd237 100644 --- a/apps/worker/src/run-queues.ts +++ b/apps/worker/src/run-queues.ts @@ -4,6 +4,7 @@ import { isExecutorId, runExecuteQueueName, runExecuteSubsetQueues, + runHostQueueName, } from "@agrippa/core"; /** A daemon ad expands to at most 2^8−1 = 255 subset queues; legit daemons advertise ≤3. */ @@ -37,6 +38,9 @@ export type RunQueueLogger = { warn(message: string): void }; */ export function selectRunQueues(input: { localExecutorIds: readonly string[]; + /** This worker's storage identity — it polls its own host's queue, where + * host-pinned central runs land (ADR-0018 amendment). Never a peer's. */ + ownWorkspaceHost?: string | null; /** Executor-id sets of live central workers (from worker_heartbeats). */ centralWorkerSets: readonly (readonly string[])[]; /** Executor ids advertised by each live runtime, with a label for logging. */ @@ -47,6 +51,7 @@ export function selectRunQueues(input: { }): string[] { const isAllowedId = input.isAllowedId ?? isExecutorId; const names = new Set(runExecuteSubsetQueues(input.localExecutorIds)); + if (input.ownWorkspaceHost) names.add(runHostQueueName(input.ownWorkspaceHost)); const centralSets = input.centralWorkerSets.map((ids) => new Set(ids)); const centrallyCovered = (ids: readonly string[]): boolean => centralSets.some((set) => ids.every((id) => set.has(id))); diff --git a/docs/design/04-execution-runtime.md b/docs/design/04-execution-runtime.md index a1381df..3dc5c50 100644 --- a/docs/design/04-execution-runtime.md +++ b/docs/design/04-execution-runtime.md @@ -89,13 +89,13 @@ finalize: usage_totals, workspace cleanup, terminal event **Follow-up steering (ADR-0018).** A finished run can be continued rather than resubmitted. The follow-up is a **new run** (`kind = 'followup'`, `parent_run_id` set) — terminal stays absorbing — that inherits its predecessor's `workspace_key`, `runtime_id`, template version, params snapshot, bindings, model resolution, resource manifest and work branch **verbatim**. That is a deliberate divergence from retry (ADR-0014 re-resolves): the inherited session id was minted by one executor against one model, and the workspace on disk contains what *that* run's manifest materialized, so re-resolution could hand a Claude session to Codex or authorize skills the directory does not have. The corollary is worth telling operators plainly: **a follow-up cannot heal a configuration failure — retry is the tool for that.** -What executes is synthesized in memory (`followup.ts`), never the compiled flow: re-entry would replay answered checkpoints, reopen closed loops, and reach `git.push` again. The synthetic template keeps the base's agents, models, resources and workspace, and replaces the phases with one `steer` step bound to the slot and model role the parent's **last agent step** used, seeded with that step's `executor_session_id`. Attaching is asserted rather than executed: the engine's existing pre-flight `isIntact` check gains the follow-up case and fails `workspace_lost` before invoking anything, because an agent working in an empty directory would report success. The steer step carries the parent step's declared subagents, skills and MCP servers — the run's `resource_manifest` still bounds what is *permitted*, so this widens what is requested and never what is allowed. Nothing is *required* of the outputs (a follow-up may only answer a question), the declared patch key is still re-diffed as evidence, and the token limit is bounded independently by `AGRIPPA_FOLLOWUP_MAX_TOKENS` (default 200k, capped by the base's own limit) — the base run's budget is sized for a whole flow and the follow-up's meter starts at zero. The operator's message is appended to the step's instructions **after** interpolation, so a message containing `${...}` is delivered verbatim rather than evaluated. Publication is out of scope for now (ADR-0012's push refuses to advance a tip it did not create), so a follow-up produces a patch and a diff while publishing stays the ancestor's business. +What executes is synthesized in memory (`followup.ts`), never the compiled flow: re-entry would replay answered checkpoints, reopen closed loops, and reach `git.push` again. The synthetic template keeps the base's agents, models, resources and workspace, and replaces the phases with one `steer` step bound to the slot and model role the parent's **last agent step** used, seeded with that step's `executor_session_id`. Attaching is asserted rather than executed: the engine's existing pre-flight `isIntact` check gains the follow-up case and fails `workspace_lost` before invoking anything, because an agent working in an empty directory would report success. The steer step carries the parent step's declared subagents, skills and MCP servers — the run's `resource_manifest` still bounds what is *permitted*, so this widens what is requested and never what is allowed. Nothing is *required* of the outputs (a follow-up may only answer a question), the declared patch key is still re-diffed as evidence, and the token limit is bounded independently by `AGRIPPA_FOLLOWUP_MAX_TOKENS` (default 200k, capped by the base's own limit) — the base run's budget is sized for a whole flow and the follow-up's meter starts at zero. The operator's message is appended to the step's instructions **after** interpolation, so a message containing `${...}` is delivered verbatim rather than evaluated. A follow-up that changed the workspace **publishes through its own approval-gated tail** (ADR-0019): the synthetic flow appends the base's `git.push`/`pr.open` steps behind a checkpoint presenting the *cumulative* patch; engine-side guards (keyed on the synthetic phase id, inexpressible in the template language) skip the whole tail for a steer with nothing to publish and carry a byte-identical approval forward instead of re-asking; and the push is an expected-tip CAS on what the chain last published (`runs.published_sha`) — any other observed tip fails typed as `publish_conflict`, with the remote never overwritten. **One live run per workspace.** A message arriving while a follow-up runs becomes the next link in the chain, which is only safe if the next link waits: jobs and leases are keyed by run id and a worker has several slots, so two links would otherwise edit one directory at once and each report success. The engine probes before the claim and **declines** (`workspace_busy` → `run.deferred`, no retries burned) while any non-terminal run shares the `workspace_key`. No grace window, unlike the affinity decline below — the blocker finishing is what releases it, and a blocker that wedges is the lease sweeper's problem. The next link also parents to the **active** one rather than to the run the reader was looking at, so it continues the conversation actually in flight. **Attach, or fail — remotely too.** Centrally the engine proves the workspace is present with `isIntact`; a daemon cannot be asked that (the server only knows the runtime is alive), so the dispatch carries `mustAttach` for a follow-up and the daemon fails `workspace_lost` rather than cloning the pinned base when the directory is gone. Cloning there would report success while dropping everything the ancestor did — the one failure this feature cannot tolerate, and the reason follow-ups are refused to daemons that do not advertise `workspace-key`. -**Central host affinity.** A remote follow-up inherits ADR-0017's runtime pin, so it lands where its workspace is. A *central* run carries no pin and workspaces are host-local, so on a multi-worker fleet a follow-up can be delivered to a worker that does not hold the directory. That worker **declines** before the claim (`workspace_on_another_host` → `run.deferred`, no status change, no retry burned) and the straggler sweep re-enqueues, giving another host a turn. Bounded at five minutes from `queued_at` — roughly ten attempts across the fleet — after which the engine stops declining and fails `workspace_lost`, because a directory nobody holds should say so rather than circulate. A per-host queue is the real fix and follows the same route-by-capability shape Phase B established. +**Central host affinity — the per-host queue** (ADR-0018 amendment). A remote follow-up inherits ADR-0017's runtime pin, so it lands where its workspace is. A *central* run pins to its **storage**: a uuid persisted at `WORKSPACE_ROOT/.agrippa-host-id` (minted once, shared by every replica mounting the volume, surviving redeploys), stamped onto `runs.workspace_host` first-writer-wins at repo checkout — with a DB-enforced `runtime_id IS NULL` guard — and inherited by follow-ups. The producer-side queue resolver (`dbRunQueueResolver`) routes any central run with a stamped host to `run.host.`, which only workers advertising that identity poll (`selectRunQueues` adds exactly its own), so follow-ups **and** resumes of initial runs land where the directory lives; every send path funnels through `enqueueRun`/`enqueueRunAfter`, so the routing has no per-call-site logic. The claim-time decline (`workspace_on_another_host` → `run.deferred`, five-minute grace from `queued_at`) survives as the deploy-skew fallback for jobs an old producer routed to a set queue. **A dead pin fails typed rather than circulating**: the `dead-host-runs` sweeper stage fails runs parked past a five-minute grace — `queued`, `running` with no lease, or `waiting_approval` fully decided — whose host no live-and-ready heartbeat (`worker_heartbeats.workspace_host`) has advertised for that long, as `workspace_lost` with a notification; a host back within the grace simply drains its queue, and a rolling deploy never trips it because the identity belongs to the volume. Scratch runs deliberately do not stamp a host this milestone (presence-attach with honest resume degradation, as shipped in M2). **Workspace retention (ADR-0018 Decision 4).** Deleting a workspace used to be a side effect of finalizing a run, which only works while a directory belongs to exactly one run. Finalize now **releases**: `finalizeRun` stamps `runs.workspace_expires_at` (`now() + AGRIPPA_WORKSPACE_RETENTION_MINUTES`, default **60**) inside the same CAS that writes the terminal status, and deletes nothing. Inside that transaction on purpose — every finalization path releases, not just the engine's: a run cancelled while queued or paused, and one failed by retry exhaustion, otherwise got no retention window at all, and a failed run's workspace is the one most worth keeping. Collection groups by `workspace_key` and requires that *every* run sharing the key has released it and the group's **latest** expiry has passed, so a follow-up extends its ancestor's workspace merely by existing and no writer has to reason about who else holds the directory. "Released" falls back to `finished_at`, which is what stops a run that finalized before retention existed — or lost its release write — from holding a directory nothing would ever collect. The server owns the policy; each host owns its own filesystem — the same split ADR-0017 made for credentials: diff --git a/docs/design/08-deployment.md b/docs/design/08-deployment.md index 69624d9..c5961cd 100644 --- a/docs/design/08-deployment.md +++ b/docs/design/08-deployment.md @@ -38,7 +38,7 @@ infra/docker-compose.yml | `ANTHROPIC_API_KEY` | Claude executor (worker only) | | `OPENAI_API_KEY` / `CODEX_API_KEY` | Codex executor's `openai` provider (worker only); both optional — a keyless worker still registers `codex-cli` and defers runs needing env auth it lacks | | `WORKER_SLOTS` | run concurrency per worker (default 2) | -| `WORKSPACE_ROOT` | workspaces volume, one directory per workspace key (default `/work/runs`) | +| `WORKSPACE_ROOT` | workspaces volume, one directory per workspace key (default `/work/runs`); its top-level `.agrippa-host-id` **is the host identity** central runs pin to (ADR-0018 amendment) — replicas sharing the volume are one host, and recreating the volume makes a new host, after which runs pinned to the old one fail `workspace_lost` once the five-minute dead-host grace passes | | `AGRIPPA_WORKSPACE_RETENTION_MINUTES` | how long a released workspace stays on disk — and therefore how long a finished run can be steered, since a follow-up attaches to that directory (default `60`). `0` restores immediate collection and gives up follow-ups; a live run in the chain holds its workspace regardless | | `AGRIPPA_FOLLOWUP_MAX_TOKENS` | per-follow-up token bound (default `200000`, capped by the template's own `maxTokens`) | | `AGRIPPA_DAEMON_WORKSPACE_TTL_DAYS` | daemon-side floor for deleting workspaces it was never told about (default `30`) | diff --git a/docs/manual/en/03-running-tasks.md b/docs/manual/en/03-running-tasks.md index 0f0f6b5..0ff19ae 100644 --- a/docs/manual/en/03-running-tasks.md +++ b/docs/manual/en/03-running-tasks.md @@ -49,7 +49,8 @@ A few things worth knowing: - **Several messages in a row become one run.** Send two thoughts in quick succession and they are delivered together rather than starting two runs. Once the follow-up begins, the next message becomes the next follow-up. - **Your message is always on the timeline**, immediately, whether or not the run starts promptly. - **There is a window.** A finished run holds its workspace for a while (one hour by default; an administrator can change it). After that the workspace is collected and **Continue** disappears — submit a new task instead. -- **Publishing stays with the original run.** A follow-up produces an updated patch and diff you can read; pushing to the branch and opening the PR remain the first run's business. +- **A follow-up that changed the work publishes it — after you approve.** When the original flow delivers to a branch, a follow-up that modified the workspace pauses at its own approval presenting the **full updated patch** (everything delivered so far, not just this message's edits); approving pushes exactly one new commit on top of what the chain last published and re-targets the same PR. A follow-up that only answered a question publishes nothing, and a patch identical to one you already approved is not re-asked. +- **A branch someone pushed to by hand is never overwritten.** If the delivery branch no longer matches what the chain last published — a teammate committed to it, or it was deleted after a merge — the follow-up's publish fails with *publish conflict* and the branch is left untouched. Reconcile by pushing manually, or submit a new task from the branch's current state. ## Cancel, retry, and failures diff --git a/docs/manual/en/06-operations.md b/docs/manual/en/06-operations.md index f786ecb..6ad687a 100644 --- a/docs/manual/en/06-operations.md +++ b/docs/manual/en/06-operations.md @@ -95,6 +95,13 @@ Documented in `infra/env/.env.example`; the full set: Workers advertise the executors they can actually run on their per-container heartbeat row (`worker_heartbeats`, refreshed every 60 s); the API refuses a submission for an executor no live worker advertises — and one whose full executor set fits no single worker. `claude-agent-sdk` and `fake` always register. `codex-cli` registers only if a Codex CLI new enough for `codex exec --ignore-user-config` / `--ignore-rules` is on the worker's `PATH` — the worker image installs one at `/opt/codex` and its build fails if that check doesn't pass. +## Workspace host affinity + +A run's workspace lives on one host's storage, and follow-ups (plus resumes) of a repository run are routed back to it: the workspaces volume identifies itself with a `.agrippa-host-id` file, workers advertise that identity on their heartbeat, and pinned runs wait on that host's own queue. Two operational consequences: + +- **The workspaces volume is the host.** Replicas sharing it are one host; deleting or recreating the volume makes a *new* host, and runs pinned to the old one fail with `workspace_lost` about five minutes after its last heartbeat — with a notification, so nothing waits silently. A rolling redeploy that keeps the volume never trips this. +- **Decommissioning a worker host** fails its parked pinned runs the same way after the same grace. That is deliberate — a directory nobody holds should say so rather than circulate. Re-submit the affected tasks on the surviving fleet. + ## Remote runtime daemons (bring your own compute) A daemon on a team member's machine can execute agent work with that machine's own CLI logins — quotas, checkpoints, audit, and git publication stay on the platform (see [design/03](../../design/03-executor-abstraction.md) and ADR-0017). diff --git a/docs/manual/zh-CN/03-running-tasks.md b/docs/manual/zh-CN/03-running-tasks.md index 3565106..0332fd1 100644 --- a/docs/manual/zh-CN/03-running-tasks.md +++ b/docs/manual/zh-CN/03-running-tasks.md @@ -49,7 +49,8 @@ - **连续几条消息会合并为一次执行。** 短时间内发两条想法,它们会一起送达,而不是启动两次执行。一旦追加执行开始,下一条消息就会成为下一次追加执行。 - **你的消息始终立刻出现在时间线上**,无论执行是否马上开始。 - **有时间窗口。** 已结束的执行会保留其工作区一段时间(默认一小时,管理员可调整)。超过之后工作区会被回收,**继续执行**也随之消失——这时请提交一个新任务。 -- **发布仍属于原执行。** 追加执行会产出更新后的补丁与差异供你查看;推送分支与创建 PR 仍由第一次执行负责。 +- **改动了工作内容的追加执行会发布——但要先经你确认。** 当原流程会交付到分支时,修改了工作区的追加执行会停在它自己的审批处,展示**完整的更新后补丁**(是迄今交付的全部内容,而不只是这条消息的改动);确认后会在该链上次发布的提交之上恰好推送一个新提交,并更新到同一个 PR。只回答了问题的追加执行不会发布任何东西;与你已确认过的补丁完全一致时也不会重复询问。 +- **人工推送过的分支绝不会被覆盖。** 如果交付分支与该链上次发布的记录不再一致——有同事直接提交过,或分支在合并后被删除——追加执行的发布会以*发布冲突*失败,分支保持原样。此时请手动推送,或基于分支当前状态提交新任务。 ## 取消、重试与失败 diff --git a/docs/manual/zh-CN/06-operations.md b/docs/manual/zh-CN/06-operations.md index 6d0dfb4..6b8d48c 100644 --- a/docs/manual/zh-CN/06-operations.md +++ b/docs/manual/zh-CN/06-operations.md @@ -105,6 +105,13 @@ docker compose -p agrippa exec -T postgres psql -U agrippa -d agrippa \ 即使执行器已注册,步骤解析到的服务商仍需要凭证。`openai` 与 `anthropic` 可用 worker 环境变量(如 `OPENAI_API_KEY`);`dashscope` 以及组织自行注册的自定义服务商**只能用项目凭证**。注意 `dashscope` 根本无法支撑 `codex-cli` 代理位——它在目录中只提供 `anthropic` 线路协议,因为 Codex ≥ 0.122 移除了百炼 OpenAI 兼容模式所用的 chat 线路 API。这类代理位请改指向提供 `openai` 协议的服务商,或改用 `claude-agent-sdk`。 +## 工作区主机亲和 + +执行的工作区存放在某一台主机的存储上,仓库执行的追加执行(以及恢复)会被路由回这台主机:工作区卷用一个 `.agrippa-host-id` 文件标识自身,工作节点在心跳中通告该标识,被固定的执行会在这台主机专属的队列上等待。由此带来两条运维影响: + +- **工作区卷就是主机。** 共享该卷的多个副本视为同一台主机;删除或重建该卷会产生一台*新*主机,固定在旧主机上的执行会在其最后一次心跳约五分钟后以 `workspace_lost` 失败——并发送通知,不会有执行静默等待。保留卷的滚动重新部署不会触发此机制。 +- **下线一台工作节点主机**会在同样的宽限期后以同样的方式让停靠在它上面的执行失败。这是有意为之——没有任何主机持有的目录应当明确说出来,而不是让执行无限流转。请在存续的节点上重新提交受影响的任务。 + ## 远程运行时守护进程(自带算力) 团队成员机器上的守护进程可以用该机器自己的 CLI 登录凭证执行智能体工作——配额、检查点、审计与 git 发布仍留在平台侧(见 [design/03](../../design/03-executor-abstraction.md) 与 ADR-0017)。 diff --git a/docs/plan/m3-plan.md b/docs/plan/m3-plan.md index 6df63a3..55d0eff 100644 --- a/docs/plan/m3-plan.md +++ b/docs/plan/m3-plan.md @@ -21,8 +21,8 @@ A follow-up produces a patch today but can never push — publication "remains t - [x] **ADR first**: [ADR-0019](../adr/0019-followup-publication-expected-tip-cas.md) + Amendment pointers appended in ADR-0012 and ADR-0018 (this plan's first commit) - [x] **P1** `applyApprovedPatch` learns `expectedTip`: parent selection (E if present, else base), branch-ref fetch for the parent object, no-op tree guard (tree == E's tree → return E, push nothing), `tip_conflict` on any other observed tip; real-git tests — create / advance / idempotent retry / conflict / no-op -- [ ] **P2** additive migration `runs.published_sha`, written by the engine in the `git.push` handler post-push (the `work_branch` pattern, crash-safe by determinism); `GitScmService` derives E from the `workspace_key` chain; `PushResult` gains `tip_conflict` and the engine maps it to `RunFailure("publish_conflict")`; `FakeScmService` learns to lie; `commitSha` on the `branch.pushed` event -- [ ] **P3** synthetic publish tail in `followupTemplate` — approval checkpoint presenting the cumulative patch (decision 2, `onTimeout: cancel`) → `git.push` → `pr.open` iff the base flow had one; engine-computed guards read *approved* and *published* separately: whole tail skipped only on an empty cumulative patch, checkpoint alone skipped on byte-identical re-approval, push/`pr.open` always run (idempotent) so an approved-but-unpublished chain completes delivery; i18n error strings; manual en+zh-CN 03; CHANGELOG +- [x] **P2** additive migration `runs.published_sha`, written by the engine in the `git.push` handler post-push (the `work_branch` pattern, crash-safe by determinism); `GitScmService` derives E from the `workspace_key` chain; `PushResult` gains `tip_conflict` and the engine maps it to `RunFailure("publish_conflict")`; `FakeScmService` learns to lie; `commitSha` on the `branch.pushed` event +- [x] **P3** synthetic publish tail in `followupTemplate` — approval checkpoint presenting the cumulative patch (decision 2, `onTimeout: cancel`) → `git.push` → `pr.open` iff the base flow had one; engine-computed guards read *approved* and *published* separately: whole tail skipped only on an empty cumulative patch, checkpoint alone skipped on byte-identical re-approval, push/`pr.open` always run (idempotent) so an approved-but-unpublished chain completes delivery; i18n error strings; manual en+zh-CN 03; CHANGELOG - Verify: ☐ compliance suite through **both transports** — publish gated by approval, empty steers skip the tail, an unchanged steer on an approved-but-unpublished chain completes delivery without a new checkpoint, conflict fails typed, chained follow-ups advance E twice; ☐ live smoke on the deployed stack: steer a finished repo run → approve the presented patch → the branch/PR advances by exactly one deterministic commit; a push retry is a no-op; a manual push to the branch followed by a second steer fails `publish_conflict` typed ### Track A — central host affinity (the per-host queue) @@ -30,8 +30,8 @@ A follow-up produces a patch today but can never push — publication "remains t ADR-0018 named the per-host queue as the real fix for "declines pre-claim for five minutes, then fails `workspace_lost`". Host identity is the identity of the *storage*, not the container — compose replicas sharing the workspaces volume share it. Recorded as an Amendment on ADR-0018. - [x] **A1** host-id helper in `@agrippa/workspace` (uuid persisted at `WORKSPACE_ROOT/.agrippa-host-id`); additive migration `runs.workspace_host` (stamped first-writer-wins at checkout, inherited by follow-up inserts, never for remote runs) + `worker_heartbeats.workspace_host`; heartbeat advertisement plumbing -- [ ] **A2** `runHostQueueName` (`run.host.`) in core; the queue resolver routes `runtime_id IS NULL AND workspace_host IS NOT NULL` to the host queue (every enqueue path already funnels through `enqueueRun`/`enqueueRunAfter`, so follow-ups **and** initial-run resumes — decision 4 — become host-bound with no call-site changes); worker polls its own host queue via `computeRunQueues`; the 5-min `WorkspaceElsewhereError` decline stays as the deploy-skew fallback -- [ ] **A3** `dead-host-runs` sweeper stage: host unheartbeated > 5 min ∧ run waited > 5 min → `finalizeRun("workspace_lost")` + notification sync (ADR-0018's "a dead pin is never re-routed", made mechanical); design 04 + 08 (volume-identity note); manual en+zh-CN 06; CHANGELOG +- [x] **A2** `runHostQueueName` (`run.host.`) in core; the queue resolver routes `runtime_id IS NULL AND workspace_host IS NOT NULL` to the host queue (every enqueue path already funnels through `enqueueRun`/`enqueueRunAfter`, so follow-ups **and** initial-run resumes — decision 4 — become host-bound with no call-site changes); worker polls its own host queue via `computeRunQueues`; the 5-min `WorkspaceElsewhereError` decline stays as the deploy-skew fallback +- [x] **A3** `dead-host-runs` sweeper stage: host unheartbeated > 5 min ∧ run waited > 5 min → `finalizeRun("workspace_lost")` + notification sync (ADR-0018's "a dead pin is never re-routed", made mechanical); design 04 + 08 (volume-identity note); manual en+zh-CN 06; CHANGELOG - Verify: ☐ two-root fleet integration test — a follow-up is claimed only by the workspace holder, zero bounces; the skew fallback still declines when an old producer sends to a set queue; a dead host's parked run fails typed at the (test-shortened) deadline ### Ride-along diff --git a/packages/core/src/queue.ts b/packages/core/src/queue.ts index d4cffca..b63d2f9 100644 --- a/packages/core/src/queue.ts +++ b/packages/core/src/queue.ts @@ -14,6 +14,20 @@ export const QUEUE_SCHEDULE_FIRE = "schedule.fire"; export const QUEUE_TRIGGER_FIRE = "trigger.fire"; export const RUN_EXECUTE_QUEUE_PREFIX = "run.execute."; +export const RUN_HOST_QUEUE_PREFIX = "run.host."; + +/** + * Queue for runs pinned to a workspace host (the per-host queue, ADR-0018 + * amendment): central runs whose `workspace_host` is stamped route here, and + * only workers advertising that storage identity poll it — the chain lands on + * the host that holds its directory instead of bouncing off claim-time + * declines. Cleanly disjoint from the executor-set family; names are only + * ever generated, never parsed back. + */ +export function runHostQueueName(hostId: string): string { + if (!hostId) throw new Error("runHostQueueName: empty host id"); + return RUN_HOST_QUEUE_PREFIX + hostId; +} /** * Queue name for a run's required executor set: `run.execute.` plus the diff --git a/packages/db/drizzle/0034_published_sha.sql b/packages/db/drizzle/0034_published_sha.sql new file mode 100644 index 0000000..38eedcf --- /dev/null +++ b/packages/db/drizzle/0034_published_sha.sql @@ -0,0 +1 @@ +ALTER TABLE "runs" ADD COLUMN "published_sha" text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0034_snapshot.json b/packages/db/drizzle/meta/0034_snapshot.json new file mode 100644 index 0000000..7e206af --- /dev/null +++ b/packages/db/drizzle/meta/0034_snapshot.json @@ -0,0 +1,5141 @@ +{ + "id": "9f5c5861-d5ba-4d41-8f49-f4dbd651ec2d", + "prevId": "b545b7c5-8e83-44af-ad56-f448dee9c512", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_prefix_uq": { + "name": "api_keys_prefix_uq", + "columns": [ + { + "expression": "prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_org_id_orgs_id_fk": { + "name": "api_keys_org_id_orgs_id_fk", + "tableFrom": "api_keys", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "api_keys_project_id_projects_id_fk": { + "name": "api_keys_project_id_projects_id_fk", + "tableFrom": "api_keys", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "api_keys_created_by_users_id_fk": { + "name": "api_keys_created_by_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_api_key_id": { + "name": "actor_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_runtime_id": { + "name": "actor_runtime_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_org_time_idx": { + "name": "audit_logs_org_time_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_org_id_orgs_id_fk": { + "name": "audit_logs_org_id_orgs_id_fk", + "tableFrom": "audit_logs", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_project_id_projects_id_fk": { + "name": "audit_logs_project_id_projects_id_fk", + "tableFrom": "audit_logs", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": ["actor_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_actor_api_key_id_api_keys_id_fk": { + "name": "audit_logs_actor_api_key_id_api_keys_id_fk", + "tableFrom": "audit_logs", + "tableTo": "api_keys", + "columnsFrom": ["actor_api_key_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "audit_logs_actor_runtime_id_runtimes_id_fk": { + "name": "audit_logs_actor_runtime_id_runtimes_id_fk", + "tableFrom": "audit_logs", + "tableTo": "runtimes", + "columnsFrom": ["actor_runtime_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "org_role": { + "name": "org_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'org_member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_org_id_orgs_id_fk": { + "name": "users_org_id_orgs_id_fk", + "tableFrom": "users", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dispatch_events": { + "name": "dispatch_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "dispatch_events_seq_uq": { + "name": "dispatch_events_seq_uq", + "columns": [ + { + "expression": "dispatch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dispatch_events_dispatch_id_dispatches_id_fk": { + "name": "dispatch_events_dispatch_id_dispatches_id_fk", + "tableFrom": "dispatch_events", + "tableTo": "dispatches", + "columnsFrom": ["dispatch_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dispatches": { + "name": "dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_row_id": { + "name": "step_row_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "runtime_id": { + "name": "runtime_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contact_at": { + "name": "last_contact_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "dispatches_claim_idx": { + "name": "dispatches_claim_idx", + "columns": [ + { + "expression": "runtime_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"dispatches\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "dispatches_step_live_uq": { + "name": "dispatches_step_live_uq", + "columns": [ + { + "expression": "step_row_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"dispatches\".\"status\" in ('pending', 'claimed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "dispatches_run_id_runs_id_fk": { + "name": "dispatches_run_id_runs_id_fk", + "tableFrom": "dispatches", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dispatches_step_row_id_run_steps_id_fk": { + "name": "dispatches_step_row_id_run_steps_id_fk", + "tableFrom": "dispatches", + "tableTo": "run_steps", + "columnsFrom": ["step_row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dispatches_runtime_id_runtimes_id_fk": { + "name": "dispatches_runtime_id_runtimes_id_fk", + "tableFrom": "dispatches", + "tableTo": "runtimes", + "columnsFrom": ["runtime_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'org_member'" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_user_id": { + "name": "accepted_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitations_org_idx": { + "name": "invitations_org_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_token_idx": { + "name": "invitations_token_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_org_id_orgs_id_fk": { + "name": "invitations_org_id_orgs_id_fk", + "tableFrom": "invitations", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_created_by_users_id_fk": { + "name": "invitations_created_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_accepted_user_id_users_id_fk": { + "name": "invitations_accepted_user_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": ["accepted_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_hash_unique": { + "name": "invitations_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_deliveries": { + "name": "notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_id": { + "name": "event_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_snippet": { + "name": "response_snippet", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_deliveries_dedupe_uq": { + "name": "notification_deliveries_dedupe_uq", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"notification_deliveries\".\"event_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_deliveries_project_idx": { + "name": "notification_deliveries_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_deliveries_pending_idx": { + "name": "notification_deliveries_pending_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"notification_deliveries\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_deliveries_endpoint_id_notification_endpoints_id_fk": { + "name": "notification_deliveries_endpoint_id_notification_endpoints_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "notification_endpoints", + "columnsFrom": ["endpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_project_id_projects_id_fk": { + "name": "notification_deliveries_project_id_projects_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_run_id_runs_id_fk": { + "name": "notification_deliveries_run_id_runs_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_deliveries_event_id_run_events_id_fk": { + "name": "notification_deliveries_event_id_run_events_id_fk", + "tableFrom": "notification_deliveries", + "tableTo": "run_events", + "columnsFrom": ["event_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_endpoints": { + "name": "notification_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ref": { + "name": "secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'zh-CN'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_endpoints_project_idx": { + "name": "notification_endpoints_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_endpoints_project_id_projects_id_fk": { + "name": "notification_endpoints_project_id_projects_id_fk", + "tableFrom": "notification_endpoints", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_endpoints_secret_ref_secrets_id_fk": { + "name": "notification_endpoints_secret_ref_secrets_id_fk", + "tableFrom": "notification_endpoints", + "tableTo": "secrets", + "columnsFrom": ["secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notification_endpoints_created_by_users_id_fk": { + "name": "notification_endpoints_created_by_users_id_fk", + "tableFrom": "notification_endpoints", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orgs": { + "name": "orgs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orgs_slug_unique": { + "name": "orgs_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_members": { + "name": "project_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_members_uq": { + "name": "project_members_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_members_project_id_projects_id_fk": { + "name": "project_members_project_id_projects_id_fk", + "tableFrom": "project_members", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_members_user_id_users_id_fk": { + "name": "project_members_user_id_users_id_fk", + "tableFrom": "project_members", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_quotas": { + "name": "project_quotas", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + }, + "token_limit": { + "name": "token_limit", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "hard_stop": { + "name": "hard_stop", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_quotas_project_id_projects_id_fk": { + "name": "project_quotas_project_id_projects_id_fk", + "tableFrom": "project_quotas", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_quotas_project_id_unique": { + "name": "project_quotas_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_resource_grants": { + "name": "project_resource_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "config_override": { + "name": "config_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_grants_uq": { + "name": "project_grants_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_resource_grants_project_id_projects_id_fk": { + "name": "project_resource_grants_project_id_projects_id_fk", + "tableFrom": "project_resource_grants", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_resource_grants_granted_by_users_id_fk": { + "name": "project_resource_grants_granted_by_users_id_fk", + "tableFrom": "project_resource_grants", + "tableTo": "users", + "columnsFrom": ["granted_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "projects_org_slug_uq": { + "name": "projects_org_slug_uq", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_org_id_orgs_id_fk": { + "name": "projects_org_id_orgs_id_fk", + "tableFrom": "projects", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_created_by_users_id_fk": { + "name": "projects_created_by_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_credentials": { + "name": "provider_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_ref": { + "name": "secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "provider_credentials_uq": { + "name": "provider_credentials_uq", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_credentials_project_id_projects_id_fk": { + "name": "provider_credentials_project_id_projects_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "provider_credentials_secret_ref_secrets_id_fk": { + "name": "provider_credentials_secret_ref_secrets_id_fk", + "tableFrom": "provider_credentials", + "tableTo": "secrets", + "columnsFrom": ["secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repo_connections": { + "name": "repo_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "credential_secret_ref": { + "name": "credential_secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "repo_connections_project_id_projects_id_fk": { + "name": "repo_connections_project_id_projects_id_fk", + "tableFrom": "repo_connections", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "repo_connections_credential_secret_ref_secrets_id_fk": { + "name": "repo_connections_credential_secret_ref_secrets_id_fk", + "tableFrom": "repo_connections", + "tableTo": "secrets", + "columnsFrom": ["credential_secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.fabri": { + "name": "fabri", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "persona_i18n": { + "name": "persona_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model_role_policy": { + "name": "default_model_role_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "fabri_org_id_orgs_id_fk": { + "name": "fabri_org_id_orgs_id_fk", + "tableFrom": "fabri", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "fabri_slug_unique": { + "name": "fabri_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "auth_secret_ref": { + "name": "auth_secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config_revision": { + "name": "config_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mcp_servers_org_id_orgs_id_fk": { + "name": "mcp_servers_org_id_orgs_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "mcp_servers_auth_secret_ref_secrets_id_fk": { + "name": "mcp_servers_auth_secret_ref_secrets_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "secrets", + "columnsFrom": ["auth_secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mcp_servers_slug_unique": { + "name": "mcp_servers_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models": { + "name": "models", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_model_id": { + "name": "provider_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rank": { + "name": "rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "models_org_id_orgs_id_fk": { + "name": "models_org_id_orgs_id_fk", + "tableFrom": "models", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "models_provider_model_id_unique": { + "name": "models_provider_model_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_model_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orchestration_templates": { + "name": "orchestration_templates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "latest_published_version_id": { + "name": "latest_published_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "orchestration_templates_org_id_orgs_id_fk": { + "name": "orchestration_templates_org_id_orgs_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orchestration_templates_scenario_id_scenarios_id_fk": { + "name": "orchestration_templates_scenario_id_scenarios_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "scenarios", + "columnsFrom": ["scenario_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orchestration_templates_latest_published_version_id_template_versions_id_fk": { + "name": "orchestration_templates_latest_published_version_id_template_versions_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "template_versions", + "columnsFrom": ["latest_published_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "orchestration_templates_created_by_users_id_fk": { + "name": "orchestration_templates_created_by_users_id_fk", + "tableFrom": "orchestration_templates", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orchestration_templates_slug_unique": { + "name": "orchestration_templates_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_catalog": { + "name": "provider_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_urls": { + "name": "base_urls", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url_hosts": { + "name": "base_url_hosts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "provider_catalog_org_id_orgs_id_fk": { + "name": "provider_catalog_org_id_orgs_id_fk", + "tableFrom": "provider_catalog", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_catalog_provider_id_unique": { + "name": "provider_catalog_provider_id_unique", + "nullsNotDistinct": false, + "columns": ["provider_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scenarios": { + "name": "scenarios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description_i18n": { + "name": "description_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "scenarios_org_id_orgs_id_fk": { + "name": "scenarios_org_id_orgs_id_fk", + "tableFrom": "scenarios", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scenarios_slug_unique": { + "name": "scenarios_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_versions": { + "name": "skill_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_ref": { + "name": "content_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_versions_uq": { + "name": "skill_versions_uq", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_versions_skill_id_skills_id_fk": { + "name": "skill_versions_skill_id_skills_id_fk", + "tableFrom": "skill_versions", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description_i18n": { + "name": "description_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_version_id": { + "name": "latest_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "skills_org_id_orgs_id_fk": { + "name": "skills_org_id_orgs_id_fk", + "tableFrom": "skills", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "skills_latest_version_id_skill_versions_id_fk": { + "name": "skills_latest_version_id_skill_versions_id_fk", + "tableFrom": "skills", + "tableTo": "skill_versions", + "columnsFrom": ["latest_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_types": { + "name": "task_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "scenario_id": { + "name": "scenario_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_i18n": { + "name": "name_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "description_i18n": { + "name": "description_i18n", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "default_faber_id": { + "name": "default_faber_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_types_uq": { + "name": "task_types_uq", + "columns": [ + { + "expression": "scenario_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_types_scenario_id_scenarios_id_fk": { + "name": "task_types_scenario_id_scenarios_id_fk", + "tableFrom": "task_types", + "tableTo": "scenarios", + "columnsFrom": ["scenario_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_types_template_id_orchestration_templates_id_fk": { + "name": "task_types_template_id_orchestration_templates_id_fk", + "tableFrom": "task_types", + "tableTo": "orchestration_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_types_default_faber_id_fabri_id_fk": { + "name": "task_types_default_faber_id_fabri_id_fk", + "tableFrom": "task_types", + "tableTo": "fabri", + "columnsFrom": ["default_faber_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.template_versions": { + "name": "template_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "template_id": { + "name": "template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "source_yaml": { + "name": "source_yaml", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "compiled": { + "name": "compiled", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "template_versions_uq": { + "name": "template_versions_uq", + "columns": [ + { + "expression": "template_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "template_versions_template_id_orchestration_templates_id_fk": { + "name": "template_versions_template_id_orchestration_templates_id_fk", + "tableFrom": "template_versions", + "tableTo": "orchestration_templates", + "columnsFrom": ["template_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "template_versions_created_by_users_id_fk": { + "name": "template_versions_created_by_users_id_fk", + "tableFrom": "template_versions", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.worker_heartbeats": { + "name": "worker_heartbeats", + "schema": "", + "columns": { + "container_id": { + "name": "container_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumers_ready_at": { + "name": "consumers_ready_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executors": { + "name": "executors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_host": { + "name": "workspace_host", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifacts": { + "name": "artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "storage_ref": { + "name": "storage_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inline": { + "name": "inline", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artifacts_run_id_runs_id_fk": { + "name": "artifacts_run_id_runs_id_fk", + "tableFrom": "artifacts", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artifacts_step_id_run_steps_id_fk": { + "name": "artifacts_step_id_run_steps_id_fk", + "tableFrom": "artifacts", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.checkpoints": { + "name": "checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approval'" + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_by": { + "name": "decided_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "checkpoints_run_ckpt_iter_uq": { + "name": "checkpoints_run_ckpt_iter_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "iteration", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "checkpoints_run_id_runs_id_fk": { + "name": "checkpoints_run_id_runs_id_fk", + "tableFrom": "checkpoints", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "checkpoints_step_id_run_steps_id_fk": { + "name": "checkpoints_step_id_run_steps_id_fk", + "tableFrom": "checkpoints", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "checkpoints_decided_by_users_id_fk": { + "name": "checkpoints_decided_by_users_id_fk", + "tableFrom": "checkpoints", + "tableTo": "users", + "columnsFrom": ["decided_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_comments": { + "name": "run_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_comments_run_idx": { + "name": "run_comments_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_comments_run_id_runs_id_fk": { + "name": "run_comments_run_id_runs_id_fk", + "tableFrom": "run_comments", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_comments_user_id_users_id_fk": { + "name": "run_comments_user_id_users_id_fk", + "tableFrom": "run_comments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_events": { + "name": "run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "run_events_run_seq_uq": { + "name": "run_events_run_seq_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_events_type_created_idx": { + "name": "run_events_type_created_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_events_run_id_runs_id_fk": { + "name": "run_events_run_id_runs_id_fk", + "tableFrom": "run_events", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "run_events_step_id_run_steps_id_fk": { + "name": "run_events_step_id_run_steps_id_fk", + "tableFrom": "run_events", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_steps": { + "name": "run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "phase_id": { + "name": "phase_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "agent_ref": { + "name": "agent_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_id": { + "name": "model_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "executor_session_id": { + "name": "executor_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage": { + "name": "usage", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "run_steps_uq": { + "name": "run_steps_uq", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "phase_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "iteration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_steps_run_id_runs_id_fk": { + "name": "run_steps_run_id_runs_id_fk", + "tableFrom": "run_steps", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.runs": { + "name": "runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "template_version_id": { + "name": "template_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "faber_id": { + "name": "faber_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "executor_id": { + "name": "executor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params_snapshot": { + "name": "params_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_resolution": { + "name": "model_resolution", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "resource_manifest": { + "name": "resource_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"mcpServers\":[],\"skills\":[]}'::jsonb" + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_key": { + "name": "workspace_key", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_expires_at": { + "name": "workspace_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workspace_host": { + "name": "workspace_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steering_message": { + "name": "steering_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_totals": { + "name": "usage_totals", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "next_event_seq": { + "name": "next_event_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "workspace_ref": { + "name": "workspace_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cancel_requested": { + "name": "cancel_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agent_bindings": { + "name": "agent_bindings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "work_branch": { + "name": "work_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_sha": { + "name": "published_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runtime_id": { + "name": "runtime_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "origin_key": { + "name": "origin_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "runs_task_number_uq": { + "name": "runs_task_number_uq", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_origin_key_uq": { + "name": "runs_origin_key_uq", + "columns": [ + { + "expression": "origin_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"runs\".\"origin_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_project_idx": { + "name": "runs_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_workspace_key_idx": { + "name": "runs_workspace_key_idx", + "columns": [ + { + "expression": "workspace_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_workspace_expiry_idx": { + "name": "runs_workspace_expiry_idx", + "columns": [ + { + "expression": "workspace_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runs\".\"workspace_expires_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_lease_sweep_idx": { + "name": "runs_lease_sweep_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runs\".\"status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "runs_runtime_reap_idx": { + "name": "runs_runtime_reap_idx", + "columns": [ + { + "expression": "runtime_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "finished_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"runs\".\"runtime_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "runs_task_id_tasks_id_fk": { + "name": "runs_task_id_tasks_id_fk", + "tableFrom": "runs", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "runs_project_id_projects_id_fk": { + "name": "runs_project_id_projects_id_fk", + "tableFrom": "runs", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_template_version_id_template_versions_id_fk": { + "name": "runs_template_version_id_template_versions_id_fk", + "tableFrom": "runs", + "tableTo": "template_versions", + "columnsFrom": ["template_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_faber_id_fabri_id_fk": { + "name": "runs_faber_id_fabri_id_fk", + "tableFrom": "runs", + "tableTo": "fabri", + "columnsFrom": ["faber_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_parent_run_id_runs_id_fk": { + "name": "runs_parent_run_id_runs_id_fk", + "tableFrom": "runs", + "tableTo": "runs", + "columnsFrom": ["parent_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_runtime_id_runtimes_id_fk": { + "name": "runs_runtime_id_runtimes_id_fk", + "tableFrom": "runs", + "tableTo": "runtimes", + "columnsFrom": ["runtime_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runs_created_by_users_id_fk": { + "name": "runs_created_by_users_id_fk", + "tableFrom": "runs", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "runs_followup_has_parent": { + "name": "runs_followup_has_parent", + "value": "\"runs\".\"kind\" <> 'followup' or \"runs\".\"parent_run_id\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type_id": { + "name": "task_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "agent_overrides": { + "name": "agent_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "latest_run_id": { + "name": "latest_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "tasks_org_id_orgs_id_fk": { + "name": "tasks_org_id_orgs_id_fk", + "tableFrom": "tasks", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_project_id_projects_id_fk": { + "name": "tasks_project_id_projects_id_fk", + "tableFrom": "tasks", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_task_type_id_task_types_id_fk": { + "name": "tasks_task_type_id_task_types_id_fk", + "tableFrom": "tasks", + "tableTo": "task_types", + "columnsFrom": ["task_type_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_latest_run_id_runs_id_fk": { + "name": "tasks_latest_run_id_runs_id_fk", + "tableFrom": "tasks", + "tableTo": "runs", + "columnsFrom": ["latest_run_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_created_by_users_id_fk": { + "name": "tasks_created_by_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.runtimes": { + "name": "runtimes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executors": { + "name": "executors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notified_offline_at": { + "name": "notified_offline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "runtimes_token_prefix_uq": { + "name": "runtimes_token_prefix_uq", + "columns": [ + { + "expression": "token_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "runtimes_org_status_idx": { + "name": "runtimes_org_status_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "runtimes_org_id_orgs_id_fk": { + "name": "runtimes_org_id_orgs_id_fk", + "tableFrom": "runtimes", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "runtimes_created_by_users_id_fk": { + "name": "runtimes_created_by_users_id_fk", + "tableFrom": "runtimes", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_schedules": { + "name": "task_schedules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type_id": { + "name": "task_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "agent_overrides": { + "name": "agent_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "task_schedules_project_idx": { + "name": "task_schedules_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_schedules_org_id_orgs_id_fk": { + "name": "task_schedules_org_id_orgs_id_fk", + "tableFrom": "task_schedules", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_schedules_project_id_projects_id_fk": { + "name": "task_schedules_project_id_projects_id_fk", + "tableFrom": "task_schedules", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "task_schedules_task_type_id_task_types_id_fk": { + "name": "task_schedules_task_type_id_task_types_id_fk", + "tableFrom": "task_schedules", + "tableTo": "task_types", + "columnsFrom": ["task_type_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "task_schedules_last_run_id_runs_id_fk": { + "name": "task_schedules_last_run_id_runs_id_fk", + "tableFrom": "task_schedules", + "tableTo": "runs", + "columnsFrom": ["last_run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "task_schedules_created_by_users_id_fk": { + "name": "task_schedules_created_by_users_id_fk", + "tableFrom": "task_schedules", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secrets": { + "name": "secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "secrets_org_id_orgs_id_fk": { + "name": "secrets_org_id_orgs_id_fk", + "tableFrom": "secrets", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "secrets_created_by_users_id_fk": { + "name": "secrets_created_by_users_id_fk", + "tableFrom": "secrets", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trigger_deliveries": { + "name": "trigger_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trigger_deliveries_dedupe_uq": { + "name": "trigger_deliveries_dedupe_uq", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"trigger_deliveries\".\"external_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "trigger_deliveries_project_idx": { + "name": "trigger_deliveries_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trigger_deliveries_pending_idx": { + "name": "trigger_deliveries_pending_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"trigger_deliveries\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trigger_deliveries_endpoint_id_trigger_endpoints_id_fk": { + "name": "trigger_deliveries_endpoint_id_trigger_endpoints_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "trigger_endpoints", + "columnsFrom": ["endpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trigger_deliveries_project_id_projects_id_fk": { + "name": "trigger_deliveries_project_id_projects_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trigger_deliveries_task_id_tasks_id_fk": { + "name": "trigger_deliveries_task_id_tasks_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "trigger_deliveries_run_id_runs_id_fk": { + "name": "trigger_deliveries_run_id_runs_id_fk", + "tableFrom": "trigger_deliveries", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trigger_endpoints": { + "name": "trigger_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type_id": { + "name": "task_type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "agent_overrides": { + "name": "agent_overrides", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_ref": { + "name": "secret_ref", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "trigger_endpoints_token_prefix_uq": { + "name": "trigger_endpoints_token_prefix_uq", + "columns": [ + { + "expression": "token_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trigger_endpoints_project_idx": { + "name": "trigger_endpoints_project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trigger_endpoints_org_id_orgs_id_fk": { + "name": "trigger_endpoints_org_id_orgs_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trigger_endpoints_project_id_projects_id_fk": { + "name": "trigger_endpoints_project_id_projects_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trigger_endpoints_task_type_id_task_types_id_fk": { + "name": "trigger_endpoints_task_type_id_task_types_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "task_types", + "columnsFrom": ["task_type_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trigger_endpoints_secret_ref_secrets_id_fk": { + "name": "trigger_endpoints_secret_ref_secrets_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "secrets", + "columnsFrom": ["secret_ref"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "trigger_endpoints_created_by_users_id_fk": { + "name": "trigger_endpoints_created_by_users_id_fk", + "tableFrom": "trigger_endpoints", + "tableTo": "users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_usage": { + "name": "token_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "model_id": { + "name": "model_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "token_usage_project_time_idx": { + "name": "token_usage_project_time_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "token_usage_run_idx": { + "name": "token_usage_run_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "token_usage_org_id_orgs_id_fk": { + "name": "token_usage_org_id_orgs_id_fk", + "tableFrom": "token_usage", + "tableTo": "orgs", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_usage_project_id_projects_id_fk": { + "name": "token_usage_project_id_projects_id_fk", + "tableFrom": "token_usage", + "tableTo": "projects", + "columnsFrom": ["project_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_usage_run_id_runs_id_fk": { + "name": "token_usage_run_id_runs_id_fk", + "tableFrom": "token_usage", + "tableTo": "runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "token_usage_step_id_run_steps_id_fk": { + "name": "token_usage_step_id_run_steps_id_fk", + "tableFrom": "token_usage", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "token_usage_model_id_models_id_fk": { + "name": "token_usage_model_id_models_id_fk", + "tableFrom": "token_usage", + "tableTo": "models", + "columnsFrom": ["model_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index cf79ad3..9a31432 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -239,6 +239,13 @@ "when": 1786842827801, "tag": "0033_workspace_host", "breakpoints": true + }, + { + "idx": 34, + "version": "7", + "when": 1786847414942, + "tag": "0034_published_sha", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/runs.ts b/packages/db/src/schema/runs.ts index 9375cb9..54af3b8 100644 --- a/packages/db/src/schema/runs.ts +++ b/packages/db/src/schema/runs.ts @@ -144,6 +144,15 @@ export const runs = pgTable( .notNull() .default({}), workBranch: text("work_branch"), // created by the git.branch system action + /** + * The snapshot commit this run's git.push landed (ADR-0019). The chain's + * expected tip E = the latest non-null value across the `workspace_key` + * group by run number — what the next follow-up's publish parents on and + * force-with-leases against. Written by the engine right after the push + * resolves (the work_branch pattern): a crash between push and record + * re-runs an idempotent push and the record lands. + */ + publishedSha: text("published_sha"), // Execution lease (ADR-0017 Decision 4, resolving ADR-0009's future work): // claiming a run takes the lease by CAS, the owning worker renews it, the // sweeper expires dead leases and re-enqueues. A second at-least-once diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 8f602de..f769cff 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -58,5 +58,6 @@ "invite_already_accepted": "This invite has already been used", "invite_self": "You cannot invite yourself", "contract_violation": "An agent output violated its contract", - "workspace_lost": "The run's workspace is no longer on this worker host" + "workspace_lost": "The run's workspace is no longer on this worker host", + "publish_conflict": "The delivery branch no longer matches what this chain last published — a human push wins; publish manually or submit a new task" } diff --git a/packages/i18n/locales/zh-CN/errors.json b/packages/i18n/locales/zh-CN/errors.json index 58d805f..71f79aa 100644 --- a/packages/i18n/locales/zh-CN/errors.json +++ b/packages/i18n/locales/zh-CN/errors.json @@ -58,5 +58,6 @@ "invite_already_accepted": "该邀请已被使用", "invite_self": "不能邀请自己", "contract_violation": "智能体输出违反了约定契约", - "workspace_lost": "该执行的工作区已不在当前工作节点上" + "workspace_lost": "该执行的工作区已不在当前工作节点上", + "publish_conflict": "交付分支与该链最近一次发布的记录不再一致——人工推送优先;请手动推送或提交新任务" } diff --git a/packages/orchestration/src/engine/deps.ts b/packages/orchestration/src/engine/deps.ts index cdd2cde..510b794 100644 --- a/packages/orchestration/src/engine/deps.ts +++ b/packages/orchestration/src/engine/deps.ts @@ -141,7 +141,13 @@ export type PushSpec = { expectedPatch?: string; }; -export type PushResult = { status: "pushed"; commitSha: string } | { status: "evidence_mismatch" }; +export type PushResult = + | { status: "pushed"; commitSha: string } + | { status: "evidence_mismatch" } + /** The expected-tip CAS lost (ADR-0019): the observed branch tip is neither + * the deterministic commit nor what this chain last published. The remote + * was not touched; the engine fails the run typed (`publish_conflict`). */ + | { status: "tip_conflict"; observedTip: string | null }; /** * Platform-side git write-path (ADR-0011): branch creation before the @@ -178,6 +184,14 @@ export type EngineDeps = { * renewal is unavailable. */ lease?: { owner: string; ttlMs?: number }; + /** + * This worker's workspace-storage identity (ADR-0018 amendment — the + * per-host queue). A central run pinned to another host's storage is + * declined before the claim: pg-boss's own retry of a crashed job bypasses + * the enqueue-side resolver, so the pin must also hold here. Absent (tests, + * daemons via RemoteExecutor deps), the guard is off. + */ + workspaceHost?: string | null; }; export type RunOutcome = diff --git a/packages/orchestration/src/engine/engine.integration.test.ts b/packages/orchestration/src/engine/engine.integration.test.ts index 04d69e0..e733624 100644 --- a/packages/orchestration/src/engine/engine.integration.test.ts +++ b/packages/orchestration/src/engine/engine.integration.test.ts @@ -23,6 +23,7 @@ import { templateVersions, tokenUsage, users, + workerHeartbeats, } from "@agrippa/db"; import { type Executor, @@ -63,6 +64,7 @@ import { findStrandedCheckpointRuns, releaseRunLease, renewRunLeases, + sweepDeadHostRuns, sweepRunLeases, transitionRun, } from "./run-lifecycle"; @@ -1107,6 +1109,27 @@ for (const transport of TRANSPORTS) { expect(deps.executor.requests.filter((r) => r.stepId === "steer")).toHaveLength(0); }); + it("a run pinned to another host's storage declines — even a crashed initial run", async () => { + // pg-boss's own retry of a crashed job bypasses the enqueue-side + // resolver, so the wrong host can still be handed a pinned run — + // failing it there would be a false workspace_lost while the holder + // is healthy (codex round 4) + const { db, runId, makeDeps } = await setupFixture(); + await db + .update(runs) + .set({ workspaceHost: "host-a", status: "running", startedAt: new Date() }) + .where(eq(runs.id, runId)); + + const away = { ...makeDeps(HAPPY_SCRIPT), workspaceHost: "host-b" }; + await expect(executeRun(away, runId)).rejects.toThrow("not on this host"); + const [row] = await db.select().from(runs).where(eq(runs.id, runId)); + expect(row?.status).toBe("running"); // untouched: no claim, no false terminal + + // on ITS host the pin is satisfied and the run proceeds normally + const home = { ...makeDeps(HAPPY_SCRIPT), workspaceHost: "host-a" }; + expect(await executeRun(home, runId)).toBe("waiting_approval"); + }); + it("a follow-up whose workspace is gone for good fails workspace_lost", async () => { const { db, runId, makeDeps, workspace } = await setupFixture(); await runToSuccess(db, runId, makeDeps); @@ -1503,6 +1526,97 @@ describe.skipIf(!dbUp)("run-lifecycle module", () => { expect(await findStrandedCheckpointRuns(db)).toContain(runId); }); + it("sweepDeadHostRuns fails only parked runs whose host is silent past grace", async () => { + const { db, runId } = await setupFixture(); + await db + .update(runs) + .set({ workspaceHost: "host-dying", queuedAt: sql`now() - interval '10 minutes'` }) + .where(eq(runs.id, runId)); + + // a live-and-ready worker advertising the host keeps the run waiting + await db.insert(workerHeartbeats).values({ + containerId: "w-live", + workspaceHost: "host-dying", + consumersReadyAt: sql`now()`, + heartbeatAt: sql`now()`, + }); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // the host goes silent — but a freshly parked run still gets its grace + await db + .update(workerHeartbeats) + .set({ heartbeatAt: sql`now() - interval '10 minutes'` }) + .where(eq(workerHeartbeats.containerId, "w-live")); + await db.update(runs).set({ queuedAt: sql`now()` }).where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // silent host + parked past grace → the dead-pin rule, typed + await db + .update(runs) + .set({ queuedAt: sql`now() - interval '10 minutes'` }) + .where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).toContain(runId); + const [row] = await db.select().from(runs).where(eq(runs.id, runId)); + expect(row?.status).toBe("failed"); + expect((row?.error as { code: string } | null)?.code).toBe("workspace_lost"); + const events = await db.select().from(runEvents).where(eq(runEvents.runId, runId)); + expect(events.some((e) => e.type === "run.failed")).toBe(true); + }); + + it("sweepDeadHostRuns measures the grace from the NEWEST decision", async () => { + const { db, runId } = await setupFixture(); + await db + .update(runs) + .set({ workspaceHost: "host-quiet", status: "waiting_approval" }) + .where(eq(runs.id, runId)); + // an hour-old decision plus a seconds-old one: the run became actionable + // just now, so the dead-pin grace has not elapsed for it (codex round 4 — + // any-old-decision satisfied the first draft's EXISTS) + await db.insert(checkpoints).values({ + runId, + checkpointId: "cp-old", + status: "approved", + decidedAt: new Date(Date.now() - 3600_000), + }); + await db.insert(checkpoints).values({ + runId, + checkpointId: "cp-new", + status: "approved", + decidedAt: new Date(), + }); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // once the NEWEST decision ages past the grace, the dead pin fails + await db + .update(checkpoints) + .set({ decidedAt: new Date(Date.now() - 600_000) }) + .where(and(eq(checkpoints.runId, runId), eq(checkpoints.checkpointId, "cp-new"))); + expect(await sweepDeadHostRuns(db)).toContain(runId); + }); + + it("sweepDeadHostRuns ignores unpinned runs and catches a leaseless running one", async () => { + const { db, runId } = await setupFixture(); + // unpinned: not a candidate however long it queues + await db + .update(runs) + .set({ queuedAt: sql`now() - interval '10 minutes'` }) + .where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).not.toContain(runId); + + // running with no lease on a silent host: the crash-recovery re-enqueue + // only the holder could ever claim + await db + .update(runs) + .set({ + workspaceHost: "host-gone", + status: "running", + leaseOwner: null, + leaseExpiresAt: sql`now() - interval '10 minutes'`, + }) + .where(eq(runs.id, runId)); + expect(await sweepDeadHostRuns(db)).toContain(runId); + }); + it("finalizeRun lets a late cancel win over a success (atomic, no read/CAS gap)", async () => { const { db, runId } = await setupFixture(); await transitionRun(db, runId, "queued", "running"); @@ -2015,7 +2129,7 @@ for (const transport of TRANSPORTS) { }); const seed = await followupSeed(db, runId, base); - const synthetic = followupTemplate(base, seed); + const synthetic = followupTemplate(base, seed, { workBranch: null }); const [steer] = synthetic.spec.phases.flatMap((p) => "steps" in p ? p.steps : [], ) as Array<{ skills: string[] }>; @@ -2729,6 +2843,261 @@ for (const transport of TRANSPORTS) { expect(fx.scm.pushes).toHaveLength(0); }); + /** A follow-up row of the v2 fixture's run — the endpoint's insert shape. */ + const v2FollowupOf = async (fx: V2Fixture, message: string): Promise => { + const [parent] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + const [head] = await fx.db + .select({ number: runs.number }) + .from(runs) + .where(eq(runs.taskId, parent?.taskId as string)) + .orderBy(desc(runs.number)) + .limit(1); + const [row] = await fx.db + .insert(runs) + .values({ + ...newRunIdentity(), + workspaceKey: parent?.workspaceKey as string, + kind: "followup", + parentRunId: fx.runId, + steeringMessage: message, + taskId: parent?.taskId as string, + projectId: parent?.projectId as string, + number: (head?.number ?? 1) + 1, + templateVersionId: parent?.templateVersionId as string, + faberId: parent?.faberId as string, + executorId: parent?.executorId as string, + agentBindings: parent?.agentBindings ?? {}, + paramsSnapshot: parent?.paramsSnapshot ?? {}, + modelResolution: parent?.modelResolution ?? {}, + resourceManifest: parent?.resourceManifest ?? { mcpServers: [], skills: [] }, + workBranch: parent?.workBranch ?? null, + workspaceRef: parent?.workspaceRef ?? null, + createdBy: parent?.createdBy as string, + }) + .returning(); + return row?.id as string; + }; + + const publishParent = async (fx: V2Fixture) => { + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("succeeded"); + return { + impl: { ...impl, steer: { kind: "succeed", output: "steered" } as FakeStepBehavior }, + rev, + }; + }; + + it("a changed follow-up publishes through its approval-gated tail, and a chain advances twice", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + expect(fx.scm.pushes).toHaveLength(1); + + // the steer changes the workspace further — cumulative evidence + fx.workspace.diffOutput = `${BIG_DIFF}+one more thing\n`; + const f1 = await v2FollowupOf(fx, "also handle the empty-list case"); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + // the tail's own approval, presenting the cumulative patch + expect(pending?.checkpointId).toBe("approve-publish"); + await decideCheckpoint(fx.db, pending?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + expect(fx.scm.pushes).toHaveLength(2); + const [f1Row] = await fx.db.select().from(runs).where(eq(runs.id, f1)); + expect(f1Row?.publishedSha).toBe("fake-2"); + // the PR is re-targeted, never duplicated (same head/base recovers) + expect(fx.scm.pullRequests).toHaveLength(1); + + // the chain advances AGAIN: a second changed steer publishes on top + fx.workspace.diffOutput = `${BIG_DIFF}+one more thing\n+and another\n`; + const f2 = await v2FollowupOf(fx, "and another"); + expect(await executeRun(fx.makeDeps(impl, rev), f2)).toBe("waiting_approval"); + const [pending2] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f2), eq(checkpoints.status, "pending"))); + await decideCheckpoint(fx.db, pending2?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); + expect(await executeRun(fx.makeDeps(impl, rev), f2)).toBe("succeeded"); + const [f2Row] = await fx.db.select().from(runs).where(eq(runs.id, f2)); + expect(f2Row?.publishedSha).toBe("fake-3"); + }); + + it("approval carry-forward binds to the EXACT bytes a publish checkpoint approved", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + + // Publication is NOT approval: the parent's bytes rode out through a + // skipped gate (outcome pass auto-publishes), so the FIRST unchanged + // steer must still present its checkpoint — codex round 4 caught the + // first draft treating every published run's artifacts as approved. + const f1 = await v2FollowupOf(fx, "just answer a question about it"); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + expect(pending?.checkpointId).toBe("approve-publish"); + // the presented digest is recorded ON the checkpoint — the record a + // later identical steer proves its carry-forward against + expect((pending?.payload as { patchSha256?: string })?.patchSha256).toMatch( + /^[0-9a-f]{64}$/, + ); + await decideCheckpoint(fx.db, pending?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + + // NOW the bytes carry a real approval: a second identical steer + // completes in one leg, checkpoint skipped, idempotent push still run + const f2 = await v2FollowupOf(fx, "one more question"); + expect(await executeRun(fx.makeDeps(impl, rev), f2)).toBe("succeeded"); + const steps = await fx.db + .select() + .from(runSteps) + .where(eq(runSteps.runId, f2)) + .orderBy(asc(runSteps.seq)); + const byId = new Map(steps.map((s) => [s.stepId, s.status])); + expect(byId.get("approve-publish")).toBe("skipped"); + expect(byId.get("push")).toBe("succeeded"); + expect( + await fx.db.select().from(checkpoints).where(eq(checkpoints.runId, f2)), + ).toHaveLength(0); + }); + + it("the tail guard reads the run's newest patch row, never a stale attempt's", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + + // a retried steer plain-INSERTS a second patch row with the same key + // and the same iteration; ordering by iteration alone ties and lets + // Postgres hand publication a stale attempt's digest. The stale row + // here carries the empty digest — picked, it would skip the whole + // tail and publish nothing. + const f1 = await v2FollowupOf(fx, "just answer a question about it"); + await fx.db.insert(artifacts).values({ + runId: f1, + artifactKey: "changes", + iteration: 1, + kind: "patch", + name: "changes", + inline: "", + sha256: new Bun.CryptoHasher("sha256").update("").digest("hex"), + createdAt: new Date(Date.now() - 3600_000), + }); + // the NEWEST row (the steer's real patch) decided: the tail RUNS and + // pauses at its approval — the stale empty digest would have skipped + // the whole tail and reported success without ever presenting one + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + expect(pending?.checkpointId).toBe("approve-publish"); + }); + + it("a follow-up recovers the ancestor's failed PR with the chain's full story", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + // the publish leg pushes, then PR creation dies for good — the run + // fails AFTER recording its publication (exactly one attempt: the + // fixture's open-pr declares no retry) + fx.scm.failNext = { pr: 1 }; + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("failed"); + const [parent] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + expect(parent?.publishedSha).toBe("fake-1"); + expect(fx.scm.pullRequests).toHaveLength(0); + + // an unchanged steer completes the delivery: approval, idempotent + // push, and a recovery PR carrying the ANCESTOR's accepted findings — + // its gate rows live on the ancestor run, and the body interpolates + // from the chain's context, not the follow-up's empty one + const script = { + ...impl, + steer: { kind: "succeed", output: "steered" } as FakeStepBehavior, + }; + const f1 = await v2FollowupOf(fx, "please finish the delivery"); + expect(await executeRun(fx.makeDeps(script, rev), f1)).toBe("waiting_approval"); + const [pending] = await fx.db + .select() + .from(checkpoints) + .where(and(eq(checkpoints.runId, f1), eq(checkpoints.status, "pending"))); + await decideCheckpoint(fx.db, pending?.id as string, { + status: "approved", + decidedBy: fx.userId, + response: { kind: "approval", outcome: "approved" }, + }); + expect(await executeRun(fx.makeDeps(script, rev), f1)).toBe("succeeded"); + expect(fx.scm.pullRequests).toHaveLength(1); + const pr = fx.scm.pullRequests[0]?.spec; + expect(pr?.body).toContain("Delivered:"); + expect(pr?.body).toContain("## Accepted review findings"); + expect(pr?.body).toContain("Unhandled null"); + }); + + it("a steer that changed nothing to publish skips the tail entirely", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await publishParent(fx); + const pushesBefore = fx.scm.pushes.length; + + fx.workspace.diffOutput = ""; + const f1 = await v2FollowupOf(fx, "what did you change and why?"); + expect(await executeRun(fx.makeDeps(impl, rev), f1)).toBe("succeeded"); + const steps = await fx.db + .select() + .from(runSteps) + .where(eq(runSteps.runId, f1)) + .orderBy(asc(runSteps.seq)); + const byId = new Map(steps.map((s) => [s.stepId, s.status])); + expect(byId.get("steer")).toBe("succeeded"); + expect(byId.get("approve-publish")).toBe("skipped"); + expect(byId.get("push")).toBe("skipped"); + expect(fx.scm.pushes).toHaveLength(pushesBefore); + const [f1Row] = await fx.db.select().from(runs).where(eq(runs.id, f1)); + expect(f1Row?.publishedSha).toBeNull(); + }); + + it("records the chain's publication and stamps the commit on the timeline", async () => { + // ADR-0019: published_sha is the chain's expected-tip record — the + // next follow-up's publish parents on it; the branch.pushed event + // carries the commit so the timeline names what actually landed + const fx = await setupV2Fixture(); + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("succeeded"); + + const [run] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + expect(run?.publishedSha).toBe("fake-1"); + const events = await fx.db.select().from(runEvents).where(eq(runEvents.runId, fx.runId)); + const pushed = events.find((e) => e.type === "branch.pushed"); + expect((pushed?.payload as { commitSha?: string } | null)?.commitSha).toBe("fake-1"); + }); + + it("a lost expected-tip CAS fails the run publish_conflict, with no record", async () => { + const fx = await setupV2Fixture(); + const { impl, rev } = await walkBigPatchToDecidedGate(fx); + fx.scm.tipConflictNext = "1111111111111111111111111111111111111111"; + + expect(await executeRun(fx.makeDeps(impl, rev), fx.runId)).toBe("failed"); + const [run] = await fx.db.select().from(runs).where(eq(runs.id, fx.runId)); + const error = run?.error as { code: string; message: string } | null; + expect(error?.code).toBe("publish_conflict"); + expect(error?.message).toContain("refusing to overwrite"); + // nothing landed, nothing recorded: the chain's E is unchanged + expect(run?.publishedSha).toBeNull(); + expect(fx.scm.pushes).toHaveLength(0); + }); + it("fails instead of publishing drifted, empty, or atomically changed evidence", async () => { for (const scenario of [ { diff: "diff --git a/drifted b/drifted\n+somebody touched this\n", atomic: false }, diff --git a/packages/orchestration/src/engine/engine.ts b/packages/orchestration/src/engine/engine.ts index 3888454..568aee6 100644 --- a/packages/orchestration/src/engine/engine.ts +++ b/packages/orchestration/src/engine/engine.ts @@ -46,10 +46,12 @@ import { UsageLimitExceededError, UsageMeter, } from "@agrippa/executor-core"; -import { and, eq, gte, inArray, max, ne, notInArray, sql } from "drizzle-orm"; +import { and, desc, eq, gte, inArray, max, ne, notInArray, sql } from "drizzle-orm"; import { upgradeCompiledTemplate } from "../compile"; import { evaluateCondition, evaluateExpression, interpolate } from "../expression"; import { + FOLLOWUP_PUBLISH_CHECKPOINT_ID, + FOLLOWUP_PUBLISH_PHASE_ID, FOLLOWUP_STEER_STEP_ID, type FollowupSeed, followupSeed, @@ -205,16 +207,18 @@ export class WorkspaceBusyError extends Error { } /** - * Raised when a follow-up's workspace is not on THIS host and might still be - * on another (ADR-0018 Consequences: central host affinity). + * Raised when a central run's workspace is not on THIS host and might still + * be on another (ADR-0018: central host affinity). * - * Remote runs are pinned, so affinity is free for them. A central run has no - * pin, and workspaces are host-local — so on a multi-worker fleet the run can - * land on a worker that does not hold the directory. Declining lets the - * sweeper re-enqueue and another worker try, which is bounded on purpose: past - * the grace window the honest answer is that nobody has it, and the engine - * fails `workspace_lost` rather than ping-ponging the run forever. The real - * fix is a per-host queue, the same route-by-capability shape Phase B took. + * Two cases share the decline. A run PINNED to another host's storage + * (`runs.workspace_host`, the per-host queue) declines unconditionally — + * pg-boss's own retry of a crashed job bypasses the enqueue-side resolver, + * so the wrong host can still be handed the job, and failing it there would + * be a false `workspace_lost` while the holder is healthy; the sweeps + * re-enqueue through the resolver onto the host queue, and a DEAD host is + * the dead-host sweeper's terminal decision. An UNPINNED follow-up (skew: a + * chain from before the pin existed) declines within a bounded grace, past + * which the engine fails `workspace_lost` rather than ping-ponging forever. */ export class WorkspaceElsewhereError extends Error { readonly code = "workspace_on_another_host"; @@ -313,7 +317,7 @@ export async function executeRun( // an ancestor's workspace, publishing included. Missing parentage costs the // inherited session (an honest fresh start); it must never cost the flow. const seed = run.kind === "followup" ? await followupSeed(db, run.parentRunId, base) : null; - const template = seed ? followupTemplate(base, seed) : base; + const template = seed ? followupTemplate(base, seed, { workBranch: run.workBranch }) : base; const [task] = await db.select().from(tasks).where(eq(tasks.id, run.taskId)); const [project] = await db.select().from(projects).where(eq(projects.id, run.projectId)); @@ -397,15 +401,29 @@ export async function executeRun( if (holder.length > 0) throw new WorkspaceBusyError(run.id, holder[0]?.id as string); } - // Central host affinity (ADR-0018). A follow-up continues a directory that - // exists on exactly one host; remote runs carry a pin, central ones do not. - // Probed BEFORE the claim so a decline costs nothing — no status change, no - // retry burned — and bounded, so a workspace nobody holds becomes an honest - // `workspace_lost` from the check inside initialize rather than a run that - // circulates forever. + // The per-host queue's claim-side guard (ADR-0018 amendment): a central run + // pinned to another host's storage is never this worker's to claim — or to + // fail. Probed before the claim, unconditional (no grace): the sweeps + // re-enqueue through the resolver onto the host queue, and a dead host is + // the dead-host sweeper's terminal decision, not this worker's guess. + if ( + run.runtimeId === null && + run.workspaceHost !== null && + deps.workspaceHost != null && + run.workspaceHost !== deps.workspaceHost + ) { + throw new WorkspaceElsewhereError(run.id); + } + + // Central host affinity for UNPINNED chains (deploy skew: a follow-up whose + // checkout predates the host pin). Probed BEFORE the claim so a decline + // costs nothing — no status change, no retry burned — and bounded, so a + // workspace nobody holds becomes an honest `workspace_lost` from the check + // inside initialize rather than a run that circulates forever. if ( run.kind === "followup" && run.runtimeId === null && + run.workspaceHost === null && // a pinned run on ITS host answers with isIntact honestly template.spec.workspace !== undefined && run.status === "queued" && Date.now() - run.queuedAt.getTime() < WORKSPACE_AFFINITY_GRACE_MS && @@ -667,6 +685,32 @@ class RunEngine { this.stepOutputs[row.stepId] = { outputs: { result: row.output ?? "" } }; } } + // A follow-up's publish tail speaks the base flow's language: the cloned + // pr.open interpolates artifact and checkpoint references the ANCESTORS + // produced (implementation plan, clarify answers), and a recovery PR — + // ancestor pushed, PR creation failed — would otherwise compose from an + // empty context. VALUES only: produced/digest state stays this run's own + // (contracts and evidence untouched), and the run's own loads below + // overwrite anything it produced itself. + if (run.kind === "followup") { + const chainIds = await this.chainRunIds(); + if (chainIds.length > 0) { + const chainArtifacts = await db + .select() + .from(artifacts) + .where(inArray(artifacts.runId, chainIds)) + .orderBy(artifacts.createdAt); + for (const a of chainArtifacts) this.artifactValues[a.artifactKey] = a.inline ?? ""; + const chainDecided = await db + .select() + .from(checkpoints) + .where(and(inArray(checkpoints.runId, chainIds), eq(checkpoints.status, "approved"))) + .orderBy(checkpoints.decidedAt); + for (const row of chainDecided) { + this.checkpointResponses[row.checkpointId] = this.responseOf(row); + } + } + } const priorArtifacts = await db .select() .from(artifacts) @@ -1040,6 +1084,11 @@ class RunEngine { loop: LoopNode | null, ): Promise<"done" | "waiting"> { const spec = step.checkpoint; + const tailSkip = await this.followupTailSkip(phase, step); + if (tailSkip) { + await this.markSkipped(phase, step, tailSkip); + return "done"; + } if (step.when && !evaluateCondition(step.when, this.expressionContext())) { await this.markSkipped(phase, step, "when_false"); return "done"; @@ -1084,6 +1133,14 @@ class RunEngine { return "done"; } const snapshot = this.sourceSnapshot(spec, step.id); + // The publish checkpoint records the EXACT digest it presents: approval + // attaches to bytes (ADR-0019), and a later identical steer proves its + // carry-forward against this record — never against publication or + // whatever patch rows a run happens to have. + const presentedPatch = + step.id === FOLLOWUP_PUBLISH_CHECKPOINT_ID && this.tailGuardState + ? (await this.tailGuardState).ownSha256 + : null; await this.db.insert(checkpoints).values({ runId: this.run.id, checkpointId: step.id, @@ -1098,6 +1155,7 @@ class RunEngine { iteration, timeoutMinutes: durationToMinutes(spec.timeout), onTimeout: spec.onTimeout, + ...(presentedPatch ? { patchSha256: presentedPatch } : {}), ...snapshot, }, }); @@ -1203,6 +1261,11 @@ class RunEngine { const startAttempt = (this.stepRows.get(this.rowKey(step.id))?.attempt ?? 0) + 1; // conditional / requires gating + const tailSkip = await this.followupTailSkip(phase, step); + if (tailSkip) { + await this.markSkipped(phase, step, tailSkip); + return; + } if (step.when && !evaluateCondition(step.when, this.expressionContext())) { await this.markSkipped(phase, step, "when_false"); return; @@ -1386,7 +1449,25 @@ class RunEngine { "workspace changed while preparing the approved snapshot — refusing to publish", ); } - await this.emit("branch.pushed", { branch }); + if (result.status === "tip_conflict") { + // The expected-tip CAS lost (ADR-0019): the branch no longer matches + // what this chain last published — a human push wins by default, the + // remote was not touched, and reconciliation is a human decision. A + // retry reproduces the refusal deterministically, so this is terminal. + throw new RunFailure( + "publish_conflict", + "the publish branch no longer matches what this chain last published — refusing to overwrite", + ); + } + // The chain's publication record, written where the work happened (the + // work_branch pattern): a crash between push and record re-runs an + // idempotent push on resume, and the record lands then. + await this.db + .update(runs) + .set({ publishedSha: result.commitSha }) + .where(eq(runs.id, this.run.id)); + this.run.publishedSha = result.commitSha; + await this.emit("branch.pushed", { branch, commitSha: result.commitSha }); return; } @@ -1443,18 +1524,26 @@ class RunEngine { */ private async composePrBody(step: SystemStep, ctx: Record): Promise { let body = interpolate(step.with.body ?? "", ctx); + // A follow-up's PR describes the CHAIN's delivery, so its waivers come + // from every run of the chain — a recovery PR (ancestor pushed, PR + // creation failed) must carry the findings the ancestor's reviewers + // accepted, not silently drop them. + const waiverRunIds = + this.run.kind === "followup" ? [this.run.id, ...(await this.chainRunIds())] : [this.run.id]; const gateRows = await this.db .select({ row: checkpoints, deciderName: users.name, deciderEmail: users.email }) .from(checkpoints) .leftJoin(users, eq(checkpoints.decidedBy, users.id)) .where( and( - eq(checkpoints.runId, this.run.id), + inArray(checkpoints.runId, waiverRunIds), eq(checkpoints.kind, "review-gate"), eq(checkpoints.status, "approved"), ), ) - .orderBy(checkpoints.iteration); + // chronological across the chain, so the last human decision per + // finding id wins exactly as it does within one run + .orderBy(checkpoints.decidedAt); // Waivers accumulate across rounds: a finding accepted in round N stays // waived unless a later round selected it for fixing. Walking iterations // in order keeps the last human decision per finding id. @@ -2158,6 +2247,106 @@ class RunEngine { row.error = error; } + /** Lazily computed once per engine leg — see {@link computeTailGuards}. */ + private tailGuardState?: Promise<{ + skipTail: boolean; + skipCheckpoint: boolean; + /** The steer's cumulative-patch digest — recorded on the publish + * checkpoint's payload so a later identical steer can prove "these + * exact bytes were approved". */ + ownSha256: string | null; + }>; + + /** The OTHER runs of this workspace chain, cached per engine leg. */ + private chainRunIdsPromise?: Promise; + + private chainRunIds(): Promise { + this.chainRunIdsPromise ??= this.db + .select({ id: runs.id }) + .from(runs) + .where(and(eq(runs.workspaceKey, this.run.workspaceKey), ne(runs.id, this.run.id))) + .then((rows) => rows.map((r) => r.id)); + return this.chainRunIdsPromise; + } + + /** + * The publish tail's guards (ADR-0019), keyed on the synthetic phase id so + * the template language never sees them. Returns the skip reason, or null + * to run the step. + */ + private async followupTailSkip( + phase: TemplatePhaseV2, + step: TemplateStepV2, + ): Promise { + if (this.run.kind !== "followup" || phase.id !== FOLLOWUP_PUBLISH_PHASE_ID) return null; + this.tailGuardState ??= this.computeTailGuards(); + const state = await this.tailGuardState; + if (state.skipTail) return "nothing_to_publish"; + if (step.id === FOLLOWUP_PUBLISH_CHECKPOINT_ID && state.skipCheckpoint) { + return "approved_bytes_carried_forward"; + } + return null; + } + + /** + * Approved and published are separate facts, and approval attaches to + * EXACT bytes (the review rounds that shaped ADR-0019 Decision 3 caught + * both conflations). The WHOLE tail is skipped only when the steer's + * cumulative patch is empty — nothing to publish. The CHECKPOINT alone is + * skipped when the patch digest equals one a human approved at a publish + * checkpoint of this chain — the digest recorded on the checkpoint's + * payload at presentation, never derived after the fact. Publication is + * deliberately NOT approval: an auto-published base flow's bytes were + * never reviewed, and a published run's other patch rows (earlier loop + * iterations) were never presented — either would let identical bytes skip + * a gate nobody answered. The conservative direction re-asks a human; it + * never pushes unapproved bytes. Push and pr.open always run when the tail + * runs: both are idempotent, so an approved-but-unpublished chain is + * completed by the next follow-up rather than skipped past. + */ + private async computeTailGuards(): Promise<{ + skipTail: boolean; + skipCheckpoint: boolean; + ownSha256: string | null; + }> { + const patchKey = this.template.spec.outputs.artifacts.find((a) => a.kind === "patch")?.key; + if (!patchKey) return { skipTail: true, skipCheckpoint: false, ownSha256: null }; + const [own] = await this.db + .select({ sha256: artifacts.sha256 }) + .from(artifacts) + .where(and(eq(artifacts.runId, this.run.id), eq(artifacts.artifactKey, patchKey))) + // createdAt tiebreaker: a retried steer INSERTS a second row with the + // same key and iteration, and the newest is what the rest of the engine + // treats as canonical (priorArtifacts loads last-write-wins) — a stale + // attempt's digest deciding publication could suppress the approval for + // bytes a human never saw + .orderBy(desc(artifacts.iteration), desc(artifacts.createdAt)) + .limit(1); + const emptyDigest = new Bun.CryptoHasher("sha256").update("").digest("hex"); + if (!own?.sha256 || own.sha256 === emptyDigest) { + return { skipTail: true, skipCheckpoint: false, ownSha256: null }; + } + + const chainIds = await this.chainRunIds(); + if (chainIds.length === 0) { + return { skipTail: false, skipCheckpoint: false, ownSha256: own.sha256 }; + } + const approvedDigests = await this.db + .select({ payload: checkpoints.payload }) + .from(checkpoints) + .where( + and( + inArray(checkpoints.runId, chainIds), + eq(checkpoints.checkpointId, FOLLOWUP_PUBLISH_CHECKPOINT_ID), + eq(checkpoints.status, "approved"), + ), + ); + const skipCheckpoint = approvedDigests.some( + (c) => (c.payload as { patchSha256?: string } | null)?.patchSha256 === own.sha256, + ); + return { skipTail: false, skipCheckpoint, ownSha256: own.sha256 }; + } + private async markSkipped( phase: TemplatePhaseV2, step: TemplateStepV2, diff --git a/packages/orchestration/src/engine/fakes.ts b/packages/orchestration/src/engine/fakes.ts index 24ac64a..937efc7 100644 --- a/packages/orchestration/src/engine/fakes.ts +++ b/packages/orchestration/src/engine/fakes.ts @@ -6,6 +6,7 @@ import type { Logger, ResolvedMcpServer, ResolvedSkill } from "@agrippa/executor import type { ArtifactStore, PullRequestSpec, + PushResult, ResourceMaterializer, ScmService, StoredArtifact, @@ -201,6 +202,10 @@ export class FakeScmService implements ScmService { readonly pushes: Array<{ runId: string; branch: string }> = []; readonly pullRequests: Array<{ runId: string; spec: PullRequestSpec }> = []; evidenceMismatchNext = false; + /** Set to make the next push lose the expected-tip CAS (ADR-0019) — the + * value is the observed tip the conflict reports. "Fails closed" is + * untestable until the fake can lie. */ + tipConflictNext: string | null | undefined = undefined; /** Set to make the next call of that action throw once (retry testing). */ failNext: Partial> = {}; @@ -217,15 +222,17 @@ export class FakeScmService implements ScmService { this.branches.push({ runId, name }); } - async push( - runId: string, - spec: { branch: string }, - ): Promise<{ status: "pushed"; commitSha: string } | { status: "evidence_mismatch" }> { + async push(runId: string, spec: { branch: string }): Promise { this.consumeFailure("push"); if (this.evidenceMismatchNext) { this.evidenceMismatchNext = false; return { status: "evidence_mismatch" }; } + if (this.tipConflictNext !== undefined) { + const observedTip = this.tipConflictNext; + this.tipConflictNext = undefined; + return { status: "tip_conflict", observedTip }; + } this.pushes.push({ runId, branch: spec.branch }); return { status: "pushed", commitSha: `fake-${this.pushes.length}` }; } diff --git a/packages/orchestration/src/engine/run-lifecycle.ts b/packages/orchestration/src/engine/run-lifecycle.ts index 92f3a6e..c5952ae 100644 --- a/packages/orchestration/src/engine/run-lifecycle.ts +++ b/packages/orchestration/src/engine/run-lifecycle.ts @@ -1,6 +1,6 @@ import { type CheckpointStoredResponse, canTransitionRun, type RunStatus } from "@agrippa/core"; -import { checkpoints, type Db, type DbOrTx, runEvents, runs } from "@agrippa/db"; -import { and, eq, inArray, isNull, sql } from "drizzle-orm"; +import { checkpoints, type Db, type DbOrTx, runEvents, runs, workerHeartbeats } from "@agrippa/db"; +import { and, eq, inArray, isNotNull, isNull, sql } from "drizzle-orm"; import { workspaceExpiryAt } from "../workspace-retention"; /** @@ -116,6 +116,80 @@ export async function findStrandedCheckpointRuns(db: DbOrTx): Promise return rows.map((r) => r.id); } +/** How long a host must be silent, and a run parked, before the dead-pin rule fires. */ +export const DEAD_HOST_GRACE_MS = 5 * 60_000; + +/** + * ADR-0018's "a dead pin fails `workspace_lost` and is never re-routed", made + * mechanical for the per-host queue. A central run pinned to a workspace host + * waits on a queue only that host's workers poll; when no live-and-ready + * heartbeat has advertised the host for the grace window AND the run itself + * has been parked at least as long, honesty beats hope: it finalizes failed, + * typed. A host back within the grace simply drains its queue and the runs + * proceed — and a rolling deploy never trips this, because the identity + * belongs to the storage and survives the container. + * + * Covered states, each with its own parked-since marker: + * - `queued` (queued_at): a follow-up or re-enqueued resume nobody can claim; + * - `running` with no lease and a stale-or-cleared expiry: the host died + * mid-run, the lease sweeper re-enqueued it, and only the holder could + * ever pick it back up; + * - `waiting_approval` with every checkpoint decided (grace past the + * decision): the resume enqueue went to a queue nobody polls, so the + * decision would never take effect. + * + * Finalization is the same CAS every other path uses, so replicas racing + * this sweep fail each run exactly once; like the worker's retry-exhaustion + * path, usage rows stay the source of truth for accounting. + */ +export async function sweepDeadHostRuns( + db: Db, + graceMs: number = DEAD_HOST_GRACE_MS, +): Promise { + const grace = sql`${Math.round(graceMs / 1000)} * interval '1 second'`; + const rows = await db + .select({ id: runs.id, status: runs.status }) + .from(runs) + .where( + and( + isNull(runs.runtimeId), + isNotNull(runs.workspaceHost), + sql`not exists (select 1 from ${workerHeartbeats} + where ${workerHeartbeats.workspaceHost} = ${runs.workspaceHost} + and ${workerHeartbeats.consumersReadyAt} is not null + and ${workerHeartbeats.heartbeatAt} > now() - ${grace})`, + sql`( + (${runs.status} = 'queued' and ${runs.queuedAt} < now() - ${grace}) + or (${runs.status} = 'running' and ${runs.leaseOwner} is null + and coalesce(${runs.leaseExpiresAt}, ${runs.queuedAt}) < now() - ${grace}) + or (${runs.status} = 'waiting_approval' + and not exists (select 1 from ${checkpoints} + where ${checkpoints.runId} = ${runs.id} + and ${checkpoints.status} = 'pending') + and (select max(${checkpoints.decidedAt}) from ${checkpoints} + where ${checkpoints.runId} = ${runs.id}) < now() - ${grace}) + )`, + ), + ); + const failed: string[] = []; + for (const row of rows) { + const result = await finalizeRun(db, { + runId: row.id, + from: row.status as RunStatus, + to: "failed", + error: { + code: "workspace_lost", + message: + "the host holding this run's workspace has stopped heartbeating — a dead pin is never re-routed", + }, + usageTotals: {}, + eventPayload: { workspaceHost: "dead" }, + }); + if (result.outcome === "finalized") failed.push(row.id); + } + return failed; +} + /** Default execution-lease TTL: survives two missed 30s renewals. */ export const RUN_LEASE_TTL_MS = 90_000; diff --git a/packages/orchestration/src/followup.ts b/packages/orchestration/src/followup.ts index 99bee79..60bb6ea 100644 --- a/packages/orchestration/src/followup.ts +++ b/packages/orchestration/src/followup.ts @@ -9,8 +9,11 @@ import { flattenPhases } from "./template-schema"; * A follow-up does not re-enter the compiled template. Re-entry replays * answered checkpoints and reopens loops the run already closed, and the * obvious target templates end in `git.push` + `pr.open` — a steering message - * would republish. Nor is it a new template kind: that would make every - * template author responsible for a flow with exactly one correct shape. + * would republish UNGATED, behind approvals answered for different bytes. The + * synthetic flow instead carries its own publish tail (ADR-0019), always + * approval-gated on the cumulative patch. Nor is it a new template kind: that + * would make every template author responsible for a flow with exactly one + * correct shape. * * What executes is built in memory from the base template: the same agents, * models, resources and workspace, with a single agent step bound to the slot @@ -21,6 +24,9 @@ import { flattenPhases } from "./template-schema"; /** The synthetic phase and step ids, stable so the timeline and tests can name them. */ export const FOLLOWUP_PHASE_ID = "followup"; export const FOLLOWUP_STEER_STEP_ID = "steer"; +/** The publish tail (ADR-0019): its phase id is what the engine's guards key on. */ +export const FOLLOWUP_PUBLISH_PHASE_ID = "publish"; +export const FOLLOWUP_PUBLISH_CHECKPOINT_ID = "approve-publish"; /** * Token bound for one follow-up. A follow-up's meter starts at zero, so the @@ -169,14 +175,30 @@ export async function followupSeed( /** * The in-memory template a follow-up executes: everything the base declares - * about *what the agent is*, and a one-step flow for what it does now. + * about *what the agent is*, and a one-step flow for what it does now — plus, + * when the base flow publishes, a publish tail of its own (ADR-0019): an + * approval checkpoint presenting the CUMULATIVE patch, then the base's own + * `git.push` and `pr.open` steps verbatim. The checkpoint is unconditional by + * decision — the ancestor's approval covered the ancestor's bytes, and even a + * base flow that auto-published does not exempt its follow-ups — while the + * engine's guards (keyed on the phase id, never expressible in the template + * language) skip the whole tail for a steer that changed nothing to publish + * and carry a byte-identical approval forward instead of re-asking. * * The instructions here are engine-authored and free of template syntax; the * operator's message is appended by the engine AFTER interpolation, so a * message containing `${...}` is delivered rather than evaluated (ADR-0018 * Decision 6). */ -export function followupTemplate(base: CompiledTemplate, seed: FollowupSeed): CompiledTemplate { +export function followupTemplate( + base: CompiledTemplate, + seed: FollowupSeed, + opts: { + /** The chain's delivery branch (runs.work_branch, inherited) — without + * one there is nothing to advance, so no tail is appended. */ + workBranch: string | null; + }, +): CompiledTemplate { const produces = seed.patchArtifactKey && base.spec.workspace ? [seed.patchArtifactKey] : []; const steer: TemplateStepV2 = { id: FOLLOWUP_STEER_STEP_ID, @@ -195,6 +217,39 @@ export function followupTemplate(base: CompiledTemplate, seed: FollowupSeed): Co onFailure: "fail", }; + // The tail exists only when every part of the publication story does: the + // base flow pushes, the base declares a workspace, the seed found a patch + // key to present, and the chain has a branch to advance. The base's own + // push/pr steps ride along verbatim — their `with` config (custom PR + // titles, base branch) is author intent the synthetic flow must not lose. + const baseSteps = flattenPhases(base.spec.phases).flatMap(({ phase }) => phase.steps); + const pushStep = baseSteps.find((s) => s.kind === "system" && s.action === "git.push"); + const prStep = baseSteps.find((s) => s.kind === "system" && s.action === "pr.open"); + const publishPhases = + pushStep && base.spec.workspace && seed.patchArtifactKey && opts.workBranch + ? [ + { + id: FOLLOWUP_PUBLISH_PHASE_ID, + name: { en: "Publish", "zh-CN": "发布" }, + steps: [ + { + id: FOLLOWUP_PUBLISH_CHECKPOINT_ID, + kind: "checkpoint", + checkpoint: { + kind: "approval", + title: { en: "Approve the updated delivery", "zh-CN": "确认更新后的交付" }, + present: [seed.patchArtifactKey], + timeout: "24h", + onTimeout: "cancel", + }, + } satisfies TemplateStepV2, + { ...pushStep, when: undefined }, + ...(prStep ? [{ ...prStep, when: undefined }] : []), + ], + }, + ] + : []; + return { ...base, spec: { @@ -205,6 +260,7 @@ export function followupTemplate(base: CompiledTemplate, seed: FollowupSeed): Co name: { en: "Follow-up", "zh-CN": "追加" }, steps: [steer], }, + ...publishPhases, ], // Nothing is required of a follow-up's outputs: it may only answer a // question. The patch key stays declared so a change to the workspace is diff --git a/packages/orchestration/src/queue.integration.test.ts b/packages/orchestration/src/queue.integration.test.ts index e25a233..b1b9461 100644 --- a/packages/orchestration/src/queue.integration.test.ts +++ b/packages/orchestration/src/queue.integration.test.ts @@ -35,7 +35,7 @@ describe.skipIf(!dbUp)("notification queue dedupe (exclusive policy)", () => { it("converges the queue to exclusive and keeps one live job per delivery", async () => { queue = await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: async () => ["fake"], + resolveRunQueue: async () => "run.execute.fake", }); const [queueRow] = (await db.execute( @@ -57,7 +57,7 @@ describe.skipIf(!dbUp)("notification queue dedupe (exclusive policy)", () => { // sweeper re-enqueue and an operator replay from becoming three runs — and // on the default 'standard' policy that key enforces nothing at all queue ??= await createRunQueue(TEST_DATABASE_URL, { - resolveRunExecutors: async () => ["fake"], + resolveRunQueue: async () => "run.execute.fake", }); const [queueRow] = (await db.execute( diff --git a/packages/orchestration/src/queue.ts b/packages/orchestration/src/queue.ts index 020afde..6482930 100644 --- a/packages/orchestration/src/queue.ts +++ b/packages/orchestration/src/queue.ts @@ -7,6 +7,7 @@ import { type RunQueue, requiredExecutorIds, runExecuteQueueName, + runHostQueueName, } from "@agrippa/core"; import { type Db, runs } from "@agrippa/db"; import { eq } from "drizzle-orm"; @@ -45,20 +46,36 @@ export async function enqueueAfterCommit( } /** - * Resolves the executor ids a run requires, so enqueueRun can derive its - * executor-set queue name. Returning [] (unknown run) falls back to the - * legacy queue — the job then fails visibly instead of being silently lost. + * Resolves a run to the queue its job belongs on — null for an unknown run + * (the enqueue then fails loudly instead of parking the job where nobody + * polls). Owning the name derivation here means every send path — submit, + * follow-up, straggler sweep, stranded-checkpoint sweep, lease sweeper, drain + * re-enqueue — routes identically with zero call-site changes. */ -export type RunExecutorResolver = (runId: string) => Promise; +export type RunQueueNameResolver = (runId: string) => Promise; -/** The standard resolver: one indexed select on the run row. */ -export function dbRunExecutorResolver(db: Db): RunExecutorResolver { +/** + * The standard resolver: one indexed select on the run row. A central run + * with a stamped workspace host routes to that host's queue (ADR-0018 + * amendment — follow-ups AND resumes of initial runs land where the + * directory lives); a daemon-pinned run's affinity is its runtime pin, so it + * routes by executor set like any other; everything else routes by the + * executor-set queue family. + */ +export function dbRunQueueResolver(db: Db): RunQueueNameResolver { return async (runId) => { const [run] = await db - .select({ executorId: runs.executorId, agentBindings: runs.agentBindings }) + .select({ + executorId: runs.executorId, + agentBindings: runs.agentBindings, + runtimeId: runs.runtimeId, + workspaceHost: runs.workspaceHost, + }) .from(runs) .where(eq(runs.id, runId)); - return run ? requiredExecutorIds(run) : []; + if (!run) return null; + if (run.runtimeId === null && run.workspaceHost) return runHostQueueName(run.workspaceHost); + return runExecuteQueueName(requiredExecutorIds(run)); }; } @@ -76,7 +93,7 @@ export function dbRunExecutorResolver(db: Db): RunExecutorResolver { */ export async function createRunQueue( connectionString: string, - opts: { resolveRunExecutors: RunExecutorResolver }, + opts: { resolveRunQueue: RunQueueNameResolver }, ): Promise { const boss = new PgBoss({ connectionString }); boss.on("error", (err: Error) => console.error("[pg-boss]", err)); @@ -130,23 +147,17 @@ export async function createRunQueue( boss, stop: () => boss.stop({ graceful: true }), async enqueueRun(runId: string): Promise { - const executorIds = await opts.resolveRunExecutors(runId); - // Post-M2-flush there is no legacy fallback queue: a run whose executor - // set cannot be derived has no consumer, so failing the enqueue loudly - // beats parking the job on a queue nobody polls. - if (executorIds.length === 0) { - throw new Error(`enqueueRun: cannot derive an executor set for run ${runId}`); - } - const name = runExecuteQueueName(executorIds); + // Post-M2-flush there is no legacy fallback queue: a run whose queue + // cannot be derived has no consumer, so failing the enqueue loudly + // beats parking the job where nobody polls. + const name = await opts.resolveRunQueue(runId); + if (!name) throw new Error(`enqueueRun: cannot derive a queue for run ${runId}`); await ensureQueue(name); await boss.send(name, { runId }, { singletonKey: runId, retryLimit: 2, retryDelay: 5 }); }, async enqueueRunAfter(runId: string, delaySeconds: number): Promise { - const executorIds = await opts.resolveRunExecutors(runId); - if (executorIds.length === 0) { - throw new Error(`enqueueRunAfter: cannot derive an executor set for run ${runId}`); - } - const name = runExecuteQueueName(executorIds); + const name = await opts.resolveRunQueue(runId); + if (!name) throw new Error(`enqueueRunAfter: cannot derive a queue for run ${runId}`); await ensureQueue(name); await boss.sendAfter( name,