From 01b51dc0e5e660cce77750a1ad9e511f1ec13b02 Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Fri, 7 Aug 2026 07:23:56 -0700
Subject: [PATCH 001/201] fix(runtime): stop Live badge and Working shimmer
after task teardown (#10985)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task list and the chat views show a Live badge and a Working
shimmer for an issue that has an active run.
> - A finished task kept the Live badge and the Working shimmer after
the run ended and the sandbox stopped.
> - The user interface reads run liveness from the
`heartbeat_runs.status` row. The run finalizer writes the terminal
status in a step that is separate from the agent `status=done` update.
When the sandbox or the run process stops between the two steps,
`heartbeat_runs.status` stays `running` forever.
> - A run row that stays `running` makes a finished task look
perpetually Live, and the user interface has no guard for an issue that
already reached a terminal status.
> - This pull request closes the invariant "environment lease released
implies the run is terminal" on the server, and adds a user interface
guard that suppresses live state for a terminal issue.
> - The benefit is that a finished task stops showing Live and Working,
both at the source (the run row) and at the surface (the badge and the
shimmer).
## Linked Issues or Issue Description
**Bug description**
- A completed task kept the Live badge and the Working shimmer after its
run ended and the sandbox was torn down.
**Steps to reproduce**
- Run an agent task to completion. Let the sandbox tear down while the
run finalizer is between the `status=done` update and the terminal
run-status write.
- Open the task list or the chat view for the finished task.
**Expected behavior**
- A finished task shows no Live badge and no Working shimmer.
**Actual behavior (before this change)**
- The finished task showed the Live badge and the Working shimmer
because its `heartbeat_runs.status` row stayed `running`.
This pull request supersedes the two separate pull requests #10954
(frontend) and #10955 (backend). It carries all of their changes for the
same race.
## What Changed
Server:
- Run teardown terminalizes a still-running or still-queued run before
it releases the environment lease. It writes `succeeded` when the issue
already reached `done`, `cancelled` when the issue is `cancelled`, and
`interrupted` otherwise. It never overwrites a status that another path
already made terminal.
- The recovery stale-lock sweep terminalizes an orphaned running run to
`interrupted` after it confirms the process and the sandbox are both
gone. It requires recorded process metadata, so it never terminalizes a
live run, a queued run, or a scheduled retry.
- Each terminal transition writes a run event.
- The stale-lock sweep continues and clears the lock when the audit
write fails. It logs the failure loudly.
- New server tests cover both invariants.
User interface:
- A shared guard suppresses the Live badge and the Working shimmer when
the issue status is terminal.
- The guard keeps non-terminal `queued` and `running` issues live.
- The guard prefers the newest issue live-status snapshot.
- New user interface tests cover the guard and the snapshot preference.
## Verification
Server:
- `pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc
--noEmit` in `server/` — 0 errors.
- `pnpm --filter server test
heartbeat-run-lease-release-terminalization.test.ts
recovery-stale-issue-lock-sweep.test.ts` — 12 tests pass.
User interface:
- `pnpm --filter @paperclipai/ui typecheck` — 0 errors.
- `pnpm exec vitest run ui/src/lib/liveIssueIds.test.ts
ui/src/lib/issue-chat-messages.test.ts` — 40 tests pass.
## Risks
- Low risk. The server change only forces a still-live run row to a
terminal status when the lease releases or when the recovery sweep
confirms the process is dead. It never overwrites an existing terminal
status, and it guards the recovery path with process metadata to avoid
terminalizing a live run.
- The user interface change is additive. The guard only suppresses live
state for a terminal issue and keeps queued and running issues live.
- No database migration. No change to any external endpoint.
## Model Used
- Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, tool use, and
code execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
...-run-lease-release-terminalization.test.ts | 212 ++++++++++
.../recovery-stale-issue-lock-sweep.test.ts | 367 ++++++++++++++++++
server/src/services/heartbeat.ts | 102 ++++-
server/src/services/recovery/service.ts | 218 ++++++++++-
ui/src/components/IssueChatThread.tsx | 12 +-
ui/src/components/TaskChatThread.tsx | 8 +-
ui/src/lib/issue-chat-messages.test.ts | 38 ++
ui/src/lib/issue-chat-messages.ts | 11 +-
ui/src/lib/liveIssueIds.test.ts | 83 ++++
ui/src/lib/liveIssueIds.ts | 62 ++-
ui/src/pages/ExecutionWorkspaceDetail.tsx | 2 +-
ui/src/pages/Inbox.tsx | 10 +-
ui/src/pages/IssueDetail.tsx | 5 +-
ui/src/pages/Issues.tsx | 3 +-
ui/src/pages/ProjectDetail.tsx | 6 +-
ui/src/pages/Routines.tsx | 2 +-
ui/src/plugins/bridge-init.ts | 3 +-
17 files changed, 1113 insertions(+), 31 deletions(-)
create mode 100644 server/src/__tests__/heartbeat-run-lease-release-terminalization.test.ts
diff --git a/server/src/__tests__/heartbeat-run-lease-release-terminalization.test.ts b/server/src/__tests__/heartbeat-run-lease-release-terminalization.test.ts
new file mode 100644
index 00000000000..4cda968220c
--- /dev/null
+++ b/server/src/__tests__/heartbeat-run-lease-release-terminalization.test.ts
@@ -0,0 +1,212 @@
+import { randomUUID } from "node:crypto";
+import { eq } from "drizzle-orm";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import {
+ agents,
+ companies,
+ createDb,
+ heartbeatRunEvents,
+ heartbeatRuns,
+ issues,
+} from "@paperclipai/db";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+
+const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() }));
+vi.mock("../telemetry.ts", () => ({ getTelemetryClient: () => mockTelemetryClient }));
+
+import { heartbeatService } from "../services/heartbeat.ts";
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+if (!embeddedPostgresSupport.supported) {
+ console.warn(
+ `Skipping embedded Postgres lease-release terminalization tests on this host: ${
+ embeddedPostgresSupport.reason ?? "unsupported environment"
+ }`,
+ );
+}
+
+describeEmbeddedPostgres("heartbeat terminalizeRunOnLeaseRelease", () => {
+ let db!: ReturnType;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-lease-release-terminal-");
+ db = createDb(tempDb.connectionString);
+ }, 20_000);
+
+ afterEach(async () => {
+ await db.delete(heartbeatRunEvents);
+ await db.delete(issues);
+ await db.delete(heartbeatRuns);
+ await db.delete(agents);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ async function seed(input: { issueStatus: string; runStatus: string }) {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ const issueId = randomUUID();
+ const runId = randomUUID();
+
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "Coder",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ });
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Terminalize on lease release",
+ status: input.issueStatus,
+ priority: "high",
+ assigneeAgentId: agentId,
+ });
+ await db.insert(heartbeatRuns).values({
+ id: runId,
+ companyId,
+ agentId,
+ status: input.runStatus,
+ invocationSource: "manual",
+ startedAt: new Date(),
+ contextSnapshot: { issueId },
+ });
+
+ const run = await db
+ .select()
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runId))
+ .then((rows) => rows[0]!);
+
+ return { companyId, agentId, issueId, runId, run };
+ }
+
+ it("forces a still-running run to succeeded when the issue already reached done", async () => {
+ // This reproduces the defect: the agent PATCHed the issue to done, but the
+ // teardown released the environment lease before the run-terminal write.
+ const { issueId, runId, run } = await seed({ issueStatus: "done", runStatus: "running" });
+
+ const heartbeat = heartbeatService(db);
+ const terminal = await heartbeat.terminalizeRunOnLeaseRelease(run);
+
+ expect(terminal.status).toBe("succeeded");
+
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("succeeded");
+
+ const issueStatus = await db
+ .select({ status: issues.status })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0]?.status);
+ expect(issueStatus).toBe("done");
+
+ const event = await db
+ .select({ message: heartbeatRunEvents.message, payload: heartbeatRunEvents.payload })
+ .from(heartbeatRunEvents)
+ .where(eq(heartbeatRunEvents.runId, runId))
+ .then((rows) => rows[0]);
+ expect(event?.message).toContain("lease release");
+ expect((event?.payload as { terminalStatus?: string } | null)?.terminalStatus).toBe("succeeded");
+ });
+
+ it("forces a still-running run to interrupted when the issue is not terminal", async () => {
+ const { runId, run } = await seed({ issueStatus: "in_progress", runStatus: "running" });
+
+ const heartbeat = heartbeatService(db);
+ const terminal = await heartbeat.terminalizeRunOnLeaseRelease(run);
+
+ expect(terminal.status).toBe("interrupted");
+
+ const row = await db
+ .select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runId))
+ .then((rows) => rows[0]);
+ expect(row?.status).toBe("interrupted");
+ expect(row?.errorCode).toBe("lease_released_before_terminal");
+ });
+
+ it("forces a still-queued run to interrupted when the lease releases before it starts", async () => {
+ // A queued run holds a lease but never reached "running". The teardown
+ // released the lease, so the run must not stay queued and show a phantom
+ // live run. A running-only update would miss it.
+ const { runId, run } = await seed({ issueStatus: "in_progress", runStatus: "queued" });
+
+ const heartbeat = heartbeatService(db);
+ const terminal = await heartbeat.terminalizeRunOnLeaseRelease(run);
+
+ expect(terminal.status).toBe("interrupted");
+
+ const row = await db
+ .select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runId))
+ .then((rows) => rows[0]);
+ expect(row?.status).toBe("interrupted");
+ expect(row?.errorCode).toBe("lease_released_before_terminal");
+ });
+
+ it("forces a still-queued run to succeeded when the issue already reached done", async () => {
+ const { runId, run } = await seed({ issueStatus: "done", runStatus: "queued" });
+
+ const heartbeat = heartbeatService(db);
+ const terminal = await heartbeat.terminalizeRunOnLeaseRelease(run);
+
+ expect(terminal.status).toBe("succeeded");
+
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("succeeded");
+ });
+
+ it("keeps an already-terminal run authoritative and records no new event", async () => {
+ const { runId, run } = await seed({ issueStatus: "done", runStatus: "failed" });
+
+ const heartbeat = heartbeatService(db);
+ const terminal = await heartbeat.terminalizeRunOnLeaseRelease(run);
+
+ expect(terminal.status).toBe("failed");
+
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("failed");
+
+ const eventCount = await db
+ .select({ id: heartbeatRunEvents.id })
+ .from(heartbeatRunEvents)
+ .where(eq(heartbeatRunEvents.runId, runId))
+ .then((rows) => rows.length);
+ expect(eventCount).toBe(0);
+ });
+});
diff --git a/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts b/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts
index de6baf9016b..718dde80c50 100644
--- a/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts
+++ b/server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts
@@ -6,6 +6,7 @@ import {
agents,
companies,
createDb,
+ heartbeatRunEvents,
heartbeatRuns,
issueComments,
issueRelations,
@@ -46,6 +47,7 @@ describeEmbeddedPostgres("recovery sweepStaleIssueLocks", () => {
await db.delete(issueRelations);
await db.delete(activityLog);
await db.delete(issues);
+ await db.delete(heartbeatRunEvents);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
@@ -223,4 +225,369 @@ describeEmbeddedPostgres("recovery sweepStaleIssueLocks", () => {
expect(first.cleared).toBe(1);
expect(second.cleared).toBe(0);
});
+
+ it("terminalizes an orphaned running run whose process is gone, then clears the lock", async () => {
+ const { companyId, agentId, runningRunId } = await seed();
+ // The run recorded a pid, but the process and its sandbox are gone. A pid
+ // this large never maps to a live process, so isPidAlive returns false.
+ // The issue is not terminal, so only the process-death authority applies.
+ await db
+ .update(heartbeatRuns)
+ .set({ processPid: 2_000_000_000 })
+ .where(eq(heartbeatRuns.id, runningRunId));
+ const issueId = randomUUID();
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Orphaned running run — terminalize then clear",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: runningRunId,
+ executionLockedAt: new Date(),
+ });
+
+ const heartbeat = heartbeatService(db);
+ const result = await heartbeat.sweepStaleIssueLocks();
+
+ expect(result.terminalizedRunIds).toEqual([runningRunId]);
+ expect(result.cleared).toBe(1);
+
+ const run = await db
+ .select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runningRunId))
+ .then((rows) => rows[0]);
+ // Process died, outcome unknown, so the backstop uses "interrupted".
+ expect(run?.status).toBe("interrupted");
+ expect(run?.errorCode).toBe("orphaned_running_run");
+
+ const lock = await db
+ .select({ checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0]);
+ expect(lock).toEqual({ checkoutRunId: null, executionRunId: null });
+
+ const event = await db
+ .select({ message: heartbeatRunEvents.message })
+ .from(heartbeatRunEvents)
+ .where(eq(heartbeatRunEvents.runId, runningRunId))
+ .then((rows) => rows[0]);
+ expect(event?.message).toContain("process and sandbox gone");
+ });
+
+ it("terminalizes a running run whose issue is terminal, even while the process stays alive (reuse-lease path)", async () => {
+ // Reuse Lease ON stops the sandbox but keeps the server process alive, so
+ // the in-memory handle and the recorded pid can both persist. The
+ // process-death authority misses this case. The issue-terminal authority
+ // catches it: the issue reached "done" while the run row stayed "running".
+ const { companyId, agentId, runningRunId } = await seed();
+ // process.pid is the live test process, so isPidAlive returns true.
+ await db
+ .update(heartbeatRuns)
+ .set({ processPid: process.pid })
+ .where(eq(heartbeatRuns.id, runningRunId));
+ const issueId = randomUUID();
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Reused sandbox stopped — issue done, run still running",
+ status: "done",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: runningRunId,
+ executionLockedAt: new Date(),
+ });
+
+ const heartbeat = heartbeatService(db);
+ const result = await heartbeat.sweepStaleIssueLocks();
+
+ expect(result.terminalizedRunIds).toEqual([runningRunId]);
+ expect(result.cleared).toBe(1);
+
+ const run = await db
+ .select({ status: heartbeatRuns.status, errorCode: heartbeatRuns.errorCode })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runningRunId))
+ .then((rows) => rows[0]);
+ // The issue is "done", so the terminal run status is "succeeded". A
+ // succeeded run carries no error code.
+ expect(run?.status).toBe("succeeded");
+ expect(run?.errorCode).toBeNull();
+
+ const lock = await db
+ .select({ checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0]);
+ expect(lock).toEqual({ checkoutRunId: null, executionRunId: null });
+
+ const event = await db
+ .select({ message: heartbeatRunEvents.message })
+ .from(heartbeatRunEvents)
+ .where(eq(heartbeatRunEvents.runId, runningRunId))
+ .then((rows) => rows[0]);
+ expect(event?.message).toContain("issue reached a terminal status");
+ });
+
+ it("terminalizes a running run to cancelled when its issue is cancelled (reuse-lease path)", async () => {
+ const { companyId, agentId, runningRunId } = await seed();
+ await db
+ .update(heartbeatRuns)
+ .set({ processPid: process.pid })
+ .where(eq(heartbeatRuns.id, runningRunId));
+ const issueId = randomUUID();
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Reused sandbox stopped — issue cancelled, run still running",
+ status: "cancelled",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: runningRunId,
+ executionLockedAt: new Date(),
+ });
+
+ const heartbeat = heartbeatService(db);
+ const result = await heartbeat.sweepStaleIssueLocks();
+
+ expect(result.terminalizedRunIds).toEqual([runningRunId]);
+
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runningRunId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("cancelled");
+ });
+
+ it("does not terminalize a running run whose process is alive and whose issue is not terminal", async () => {
+ const { companyId, agentId, runningRunId } = await seed();
+ // process.pid is the live test process, so isPidAlive returns true.
+ await db
+ .update(heartbeatRuns)
+ .set({ processPid: process.pid })
+ .where(eq(heartbeatRuns.id, runningRunId));
+ const issueId = randomUUID();
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Live run — preserve",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: runningRunId,
+ executionLockedAt: new Date(),
+ });
+
+ const heartbeat = heartbeatService(db);
+ const result = await heartbeat.sweepStaleIssueLocks();
+
+ expect(result.terminalizedRunIds).toEqual([]);
+ expect(result.cleared).toBe(0);
+
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runningRunId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("running");
+ });
+
+ it("does not terminalize a live run that a terminal issue and an active issue both reference", async () => {
+ // A stale lock on a terminal issue and the real lock on an active issue can
+ // point at the same running run. The terminal reference alone must not
+ // terminalize the run, because the run is still live for the active issue.
+ const { companyId, agentId, runningRunId } = await seed();
+ // process.pid is the live test process, so isPidAlive returns true.
+ await db
+ .update(heartbeatRuns)
+ .set({ processPid: process.pid })
+ .where(eq(heartbeatRuns.id, runningRunId));
+
+ const terminalIssueId = randomUUID();
+ const activeIssueId = randomUUID();
+ await db.insert(issues).values([
+ {
+ id: terminalIssueId,
+ companyId,
+ title: "Terminal issue holds a stale lock on the shared run",
+ status: "done",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: null,
+ },
+ {
+ id: activeIssueId,
+ companyId,
+ title: "Active issue owns the live shared run",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: runningRunId,
+ executionLockedAt: new Date(),
+ },
+ ]);
+
+ const heartbeat = heartbeatService(db);
+ const result = await heartbeat.sweepStaleIssueLocks();
+
+ // The run stays live, so the sweep terminalizes nothing and clears nothing.
+ expect(result.terminalizedRunIds).toEqual([]);
+ expect(result.cleared).toBe(0);
+
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runningRunId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("running");
+
+ const activeLock = await db
+ .select({ checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
+ .from(issues)
+ .where(eq(issues.id, activeIssueId))
+ .then((rows) => rows[0]);
+ expect(activeLock).toEqual({ checkoutRunId: runningRunId, executionRunId: runningRunId });
+ });
+
+ it("does not terminalize a shared live run when its context snapshot names the terminal issue", async () => {
+ // The run context snapshot names the terminal issue. The context-snapshot
+ // fallback in terminalizeOrphanedRunningRun could read that terminal status
+ // and terminalize the run. An active issue still owns the run, so the sweep
+ // must suppress the fallback and keep the run live.
+ const { companyId, agentId, runningRunId } = await seed();
+ // process.pid is the live test process, so isPidAlive returns true.
+ await db
+ .update(heartbeatRuns)
+ .set({ processPid: process.pid })
+ .where(eq(heartbeatRuns.id, runningRunId));
+
+ const terminalIssueId = randomUUID();
+ const activeIssueId = randomUUID();
+ // The run context snapshot names the terminal issue. This is the path the
+ // shared-run guard must still block.
+ await db
+ .update(heartbeatRuns)
+ .set({ contextSnapshot: { issueId: terminalIssueId } })
+ .where(eq(heartbeatRuns.id, runningRunId));
+ await db.insert(issues).values([
+ {
+ id: terminalIssueId,
+ companyId,
+ title: "Terminal issue named in the run context snapshot",
+ status: "done",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: null,
+ },
+ {
+ id: activeIssueId,
+ companyId,
+ title: "Active issue owns the live shared run",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: runningRunId,
+ executionLockedAt: new Date(),
+ },
+ ]);
+
+ const heartbeat = heartbeatService(db);
+ const result = await heartbeat.sweepStaleIssueLocks();
+
+ // The run stays live, so the sweep terminalizes nothing and clears nothing.
+ expect(result.terminalizedRunIds).toEqual([]);
+ expect(result.cleared).toBe(0);
+
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runningRunId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("running");
+
+ const activeLock = await db
+ .select({ checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
+ .from(issues)
+ .where(eq(issues.id, activeIssueId))
+ .then((rows) => rows[0]);
+ expect(activeLock).toEqual({ checkoutRunId: runningRunId, executionRunId: runningRunId });
+ });
+
+ it("still clears the lock when the audit write fails after terminalization", async () => {
+ const { companyId, agentId, runningRunId } = await seed();
+ // The run recorded a pid that never maps to a live process, so the sweep
+ // decides to terminalize it. The issue is not terminal, so the
+ // process-death authority drives the terminalization here.
+ await db
+ .update(heartbeatRuns)
+ .set({ processPid: 2_000_000_000 })
+ .where(eq(heartbeatRuns.id, runningRunId));
+ const issueId = randomUUID();
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ title: "Audit write fails — still clear the lock",
+ status: "in_progress",
+ priority: "high",
+ assigneeAgentId: agentId,
+ checkoutRunId: runningRunId,
+ executionRunId: runningRunId,
+ executionLockedAt: new Date(),
+ });
+
+ // Make only the audit-event insert fail. The run update commits the
+ // terminal status first, so the audit write is best-effort. The sweep must
+ // catch the failure and still clear the lock.
+ const realInsert = db.insert.bind(db);
+ const insertSpy = vi.spyOn(db, "insert").mockImplementation((table) => {
+ if (table === heartbeatRunEvents) {
+ throw new Error("simulated audit write failure");
+ }
+ return realInsert(table);
+ });
+
+ try {
+ const heartbeat = heartbeatService(db);
+ const result = await heartbeat.sweepStaleIssueLocks();
+
+ expect(result.terminalizedRunIds).toEqual([runningRunId]);
+ expect(result.cleared).toBe(1);
+ } finally {
+ insertSpy.mockRestore();
+ }
+
+ // The run reached its terminal status even though the audit write failed.
+ const runStatus = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, runningRunId))
+ .then((rows) => rows[0]?.status);
+ expect(runStatus).toBe("interrupted");
+
+ // The sweep cleared the lock in the same pass.
+ const lock = await db
+ .select({ checkoutRunId: issues.checkoutRunId, executionRunId: issues.executionRunId })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0]);
+ expect(lock).toEqual({ checkoutRunId: null, executionRunId: null });
+
+ // The audit write failed, so no run event exists for this run.
+ const events = await db
+ .select({ id: heartbeatRunEvents.id })
+ .from(heartbeatRunEvents)
+ .where(eq(heartbeatRunEvents.runId, runningRunId));
+ expect(events).toEqual([]);
+ });
});
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 6941b33ba8f..11e9e247255 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -8825,11 +8825,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
runId: string,
status: string,
patch?: Partial,
+ ) {
+ return setRunStatusFromLive(runId, status, ["running"], patch);
+ }
+
+ // Move a run to a new status only when its current status is one of
+ // `fromStatuses`. The compare-and-set is a single conditional update, so a
+ // concurrent path can win the race. When this update matches nothing, the
+ // function reads the current row and reports updated=false, so the caller can
+ // keep the terminal outcome that another path already wrote.
+ async function setRunStatusFromLive(
+ runId: string,
+ status: string,
+ fromStatuses: string[],
+ patch?: Partial,
) {
const updated = await db
.update(heartbeatRuns)
.set({ status, ...patch, updatedAt: new Date() })
- .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.status, "running")))
+ .where(and(eq(heartbeatRuns.id, runId), inArray(heartbeatRuns.status, fromStatuses)))
.returning()
.then((rows) => rows[0] ?? null);
@@ -8855,6 +8869,75 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
return { run: current, updated: false as const };
}
+ // Invariant: when a run releases its environment lease, the run row must be
+ // terminal. The finalizer writes the terminal status in a step that is
+ // separate from the agent status=done PATCH. If the sandbox or the run
+ // process stops between the two steps, heartbeat_runs.status stays "running".
+ // The UI reads liveness from that row, so a finished task shows "Live"
+ // forever. This function closes the gap in the run teardown: when the run is
+ // still running or queued, it forces a terminal status before the lease is
+ // released. It never overwrites a status that another path already made
+ // terminal.
+ async function terminalizeRunOnLeaseRelease(
+ run: typeof heartbeatRuns.$inferSelect,
+ ): Promise {
+ if (isHeartbeatRunTerminalStatus(run.status)) return run;
+ if (run.status !== "running" && run.status !== "queued") return run;
+
+ // Choose the terminal status that reflects the true outcome. When the issue
+ // already reached a terminal status, the run reached its goal, so use the
+ // matching terminal run status. Otherwise the teardown cut the run short,
+ // so use "interrupted".
+ const issueId = readNonEmptyString(parseObject(run.contextSnapshot).issueId);
+ let terminalStatus: "succeeded" | "cancelled" | "interrupted" = "interrupted";
+ if (issueId) {
+ const issueStatus = await db
+ .select({ status: issues.status })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0]?.status ?? null);
+ if (issueStatus === "done") terminalStatus = "succeeded";
+ else if (issueStatus === "cancelled") terminalStatus = "cancelled";
+ }
+
+ const message =
+ `run terminalized on environment lease release: heartbeat_runs.status was still ${run.status} at teardown`;
+ // Match both "running" and "queued". A queued run has released its lease but
+ // never reached "running", so a running-only update would miss it and leave
+ // a phantom live run behind.
+ const write = await setRunStatusFromLive(run.id, terminalStatus, ["running", "queued"], {
+ finishedAt: run.finishedAt ?? new Date(),
+ error: run.error ?? (terminalStatus === "interrupted" ? message : null),
+ errorCode: run.errorCode ?? (terminalStatus === "interrupted" ? "lease_released_before_terminal" : null),
+ });
+ if (!write.updated) {
+ // Another path already finalized the run. Keep that terminal outcome.
+ return write.run ?? run;
+ }
+
+ const terminalRun = write.run;
+ if (terminalRun) {
+ await appendRunEvent(terminalRun, await nextRunEventSeq(terminalRun.id), {
+ eventType: "lifecycle",
+ stream: "system",
+ level: terminalStatus === "interrupted" ? "warn" : "info",
+ message,
+ payload: {
+ previousStatus: run.status,
+ terminalStatus,
+ reason: "environment_lease_release",
+ ...(issueId ? { issueId } : {}),
+ },
+ }).catch((eventErr) => {
+ logger.warn(
+ { err: eventErr, runId: run.id },
+ "failed to append run event for lease-release terminalization",
+ );
+ });
+ }
+ return terminalRun ?? run;
+ }
+
function publishRunLifecyclePluginEvent(run: typeof heartbeatRuns.$inferSelect) {
const eventType =
run.status === "running"
@@ -16193,7 +16276,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
}
} finally {
- const latestRun = await getRun(run.id).catch(() => null);
+ let latestRun = await getRun(run.id).catch(() => null);
+ // Close the invariant "environment lease released implies the run is
+ // terminal". When the teardown reaches this point with the run still
+ // running or queued, force a terminal status before the lease is
+ // released, so the UI never shows a finished task as "Live".
+ if (latestRun) {
+ latestRun = await terminalizeRunOnLeaseRelease(latestRun).catch((terminalizeErr) => {
+ logger.error(
+ { err: terminalizeErr, runId: run.id },
+ "failed to terminalize run before environment lease release",
+ );
+ return latestRun;
+ });
+ }
await releaseEnvironmentLeasesForRun({
runId: run.id,
companyId: run.companyId,
@@ -18935,6 +19031,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
reconcileStrandedAssignedIssues,
+ terminalizeRunOnLeaseRelease,
+
sweepStaleIssueLocks,
buildIssueGraphLivenessAutoRecoveryPreview,
diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts
index f9545fa66b6..c0f2a5e0326 100644
--- a/server/src/services/recovery/service.ts
+++ b/server/src/services/recovery/service.ts
@@ -5477,22 +5477,178 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return Math.max(1, Math.floor(asNumber(raw, fallback)));
}
+ // Backstop reconciler: terminalizes a "running" run that can no longer reach a
+ // terminal status on its own. The run finalizer writes the terminal status in
+ // a step that is separate from the agent status=done PATCH. When the teardown
+ // stops between the two steps, heartbeat_runs.status stays "running" forever.
+ // The UI reads liveness from that row, so the task shows "Live" forever. This
+ // function forces the run to a terminal status and records a run event, so the
+ // state is auditable. It never overwrites a status that another path already
+ // made terminal.
+ //
+ // Two independent authorities terminalize the run. Either one is enough:
+ //
+ // - Issue-terminal authority: the run's issue already reached a terminal
+ // status (done or cancelled), but the run row is still "running". A healthy
+ // run always terminalizes its own row before or just after the issue reaches
+ // a terminal status, so a lasting "running" row under a terminal issue is
+ // orphaned. This authority does not depend on process death. It is the only
+ // authority that catches the reuse-lease path: the release stops the sandbox
+ // but keeps the server process alive, so the in-memory handle and the
+ // recorded pid can both persist.
+ // - Process-death authority: the run has no in-memory handle and its recorded
+ // process and process group are both gone. This catches a hard server crash
+ // that skipped the graceful teardown, even when the issue is not terminal.
+ async function terminalizeOrphanedRunningRun(
+ run: typeof heartbeatRuns.$inferSelect,
+ options?: {
+ // The terminal run status implied by a referencing issue. The caller
+ // passes it when it already knows the issue that holds the run in a lock
+ // column. It maps issue "done" to "succeeded" and issue "cancelled" to
+ // "cancelled". A null value means the referencing issue is not terminal.
+ referencingIssueTerminalStatus?: "succeeded" | "cancelled" | null;
+ // True when an active (non-terminal) issue still holds this run in a lock
+ // column. The run is live for that active issue, so the caller forbids the
+ // issue-terminal authority. This flag also suppresses the context-snapshot
+ // fallback below. Without it, a terminal issue named in the run context
+ // snapshot would still terminalize the shared run and defeat the guard.
+ runReferencedByActiveIssue?: boolean;
+ },
+ ): Promise<{ terminalized: boolean; status: string }> {
+ // Act only on a run in "running" status. A "queued" run has no process yet,
+ // and a "scheduled_retry" run has no process on purpose because it waits to
+ // retry. Neither is orphaned, so this function must not terminalize them.
+ if (run.status !== "running") return { terminalized: false, status: run.status };
+
+ const pid = run.processPid ?? null;
+ const processGroupId = run.processGroupId ?? null;
+
+ // Issue-terminal authority. When the run's issue is terminal, the run row is
+ // orphaned regardless of process or handle state. Prefer the referencing
+ // issue status that the caller passed, because a lock column is the direct
+ // link from the stuck "Live" issue to this run. Fall back to the issue id in
+ // the run context snapshot when the caller passed nothing. Skip the fallback
+ // when an active issue still references the run. The run is live for that
+ // active issue, so a terminal issue named in the context snapshot must not
+ // terminalize it.
+ let issueTerminalStatus: "succeeded" | "cancelled" | null =
+ options?.referencingIssueTerminalStatus ?? null;
+ const issueId = issueIdFromRunContext(run.contextSnapshot);
+ if (!issueTerminalStatus && !options?.runReferencedByActiveIssue && issueId) {
+ const issueStatus = await db
+ .select({ status: issues.status })
+ .from(issues)
+ .where(eq(issues.id, issueId))
+ .then((rows) => rows[0]?.status ?? null);
+ if (issueStatus === "done") issueTerminalStatus = "succeeded";
+ else if (issueStatus === "cancelled") issueTerminalStatus = "cancelled";
+ }
+
+ // Process-death authority. The run is live only when a process still backs
+ // it. Check the in-memory handle first, then the recorded pid and process
+ // group. Require recorded process metadata, so this authority never fires on
+ // a run that has not yet stored its pid.
+ let processGone = false;
+ if (!runningProcesses.get(run.id)) {
+ if (typeof pid === "number" || typeof processGroupId === "number") {
+ const processAlive =
+ (typeof pid === "number" && isPidAlive(pid)) ||
+ (typeof processGroupId === "number" && isProcessGroupAlive(processGroupId));
+ processGone = !processAlive;
+ }
+ }
+
+ // Neither authority applies. The run is still live, so leave it alone.
+ if (!issueTerminalStatus && !processGone) {
+ return { terminalized: false, status: run.status };
+ }
+
+ const authority = issueTerminalStatus ? "issue_terminal" : "process_gone";
+ const terminalStatus = issueTerminalStatus ?? "interrupted";
+ const errorCode = issueTerminalStatus
+ ? "orphaned_running_run_issue_terminal"
+ : "orphaned_running_run";
+ const message =
+ authority === "issue_terminal"
+ ? "run terminalized by recovery backstop: issue reached a terminal status while heartbeat_runs.status stayed live"
+ : "run terminalized by recovery backstop: process and sandbox gone while heartbeat_runs.status stayed live";
+
+ const now = new Date();
+ const updated = await db
+ .update(heartbeatRuns)
+ .set({
+ status: terminalStatus,
+ finishedAt: run.finishedAt ?? now,
+ error: run.error ?? (terminalStatus === "interrupted" ? message : null),
+ errorCode: run.errorCode ?? (terminalStatus === "interrupted" ? errorCode : null),
+ updatedAt: now,
+ })
+ .where(and(eq(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "running")))
+ .returning()
+ .then((rows) => rows[0] ?? null);
+ if (!updated) {
+ // Another path finalized the run between the read and this write. Keep
+ // that terminal outcome authoritative.
+ const [current] = await db
+ .select({ status: heartbeatRuns.status })
+ .from(heartbeatRuns)
+ .where(eq(heartbeatRuns.id, run.id));
+ return { terminalized: false, status: current?.status ?? run.status };
+ }
+
+ runningProcesses.delete(run.id);
+ // The run update above already committed the terminal status. The audit
+ // event is best-effort: if the insert fails, the caller must still treat
+ // the run as terminalized and clear the lock in the same sweep. So catch
+ // the failure, log it, and continue. A thrown error here would abort the
+ // sweep and leave the stale lock in place.
+ try {
+ await appendRecoveryRunEvent(updated, {
+ level: "warn",
+ message,
+ payload: {
+ source: "recovery.sweep_stale_issue_locks",
+ authority,
+ previousStatus: run.status,
+ terminalStatus,
+ ...(issueId ? { issueId } : {}),
+ pid,
+ processGroupId,
+ },
+ });
+ } catch (error) {
+ logger.error(
+ { err: error, runId: run.id, previousStatus: run.status },
+ "failed to append recovery run event after terminalizing orphaned run; run stays terminal and the sweep clears the lock",
+ );
+ }
+ logger.warn(
+ { runId: run.id, authority, previousStatus: run.status, terminalStatus, issueId, pid, processGroupId },
+ "terminalized orphaned running heartbeat run in stale-lock sweep",
+ );
+ return { terminalized: true, status: updated.status };
+ }
+
// Backstop sweeper: clears stale lock columns on issues whose checkoutRunId
// or executionRunId points at a heartbeat_runs row that is either missing or
// in a terminal status. Provides self-heal for stale locks that fell outside
// releaseIssueExecutionAndPromote / clearCheckoutRunIfTerminal / adoption.
- // Idempotent and safe: clears at most one row's worth of lock columns per
- // candidate, and only when the referenced run row is unambiguously terminal.
+ // Before it evaluates cleanability, it terminalizes any referenced run that
+ // still claims to be live but can no longer reach a terminal status on its
+ // own, so a stuck "running" run can no longer block the sweep. Idempotent and
+ // safe: clears at most one row's worth of lock columns per candidate.
async function sweepStaleIssueLocks() {
const result = {
cleared: 0,
issueIds: [] as string[],
+ terminalizedRunIds: [] as string[],
};
const candidates = await db
.select({
id: issues.id,
companyId: issues.companyId,
+ status: issues.status,
checkoutRunId: issues.checkoutRunId,
executionRunId: issues.executionRunId,
})
@@ -5511,13 +5667,61 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const runRows =
referencedRunIds.length > 0
? await db
- .select({ id: heartbeatRuns.id, status: heartbeatRuns.status })
+ .select()
.from(heartbeatRuns)
.where(inArray(heartbeatRuns.id, referencedRunIds))
: [];
const runStatusById = new Map();
for (const row of runRows) runStatusById.set(row.id, row.status);
+ // Collect the runs that a non-terminal issue still references. Such a run is
+ // the live run of an active issue. A different, terminal issue can also hold
+ // the same run id in a stale lock column. The terminal reference alone must
+ // not terminalize a run that an active issue still owns, so exclude these
+ // runs from the issue-terminal authority below.
+ const runIdsReferencedByActiveIssue = new Set();
+ for (const issue of candidates) {
+ if (issue.status === "done" || issue.status === "cancelled") continue;
+ for (const runId of [issue.checkoutRunId, issue.executionRunId]) {
+ if (runId) runIdsReferencedByActiveIssue.add(runId);
+ }
+ }
+
+ // Map each referenced run to the terminal run status implied by its
+ // referencing issue. When a terminal issue still holds the run in a lock
+ // column, that run is orphaned: the issue is the stuck "Live" task the UI
+ // shows. A "done" issue implies "succeeded"; a "cancelled" issue implies
+ // "cancelled". Skip a run that an active issue also references, because that
+ // run is still live for the active issue.
+ const issueTerminalStatusByRunId = new Map();
+ for (const issue of candidates) {
+ const implied =
+ issue.status === "done"
+ ? "succeeded"
+ : issue.status === "cancelled"
+ ? "cancelled"
+ : null;
+ if (!implied) continue;
+ for (const runId of [issue.checkoutRunId, issue.executionRunId]) {
+ if (runId && !runIdsReferencedByActiveIssue.has(runId)) {
+ issueTerminalStatusByRunId.set(runId, implied);
+ }
+ }
+ }
+
+ // Pre-pass: terminalize any referenced run that still claims to be live but
+ // can no longer reach a terminal status on its own. This lets the sweep
+ // clear the lock in the same pass instead of waiting for the run to reach a
+ // terminal status by another route.
+ for (const row of runRows) {
+ const outcome = await terminalizeOrphanedRunningRun(row, {
+ referencingIssueTerminalStatus: issueTerminalStatusByRunId.get(row.id) ?? null,
+ runReferencedByActiveIssue: runIdsReferencedByActiveIssue.has(row.id),
+ });
+ runStatusById.set(row.id, outcome.status);
+ if (outcome.terminalized) result.terminalizedRunIds.push(row.id);
+ }
+
const isCleanable = (runId: string | null) => {
if (!runId) return true;
const status = runStatusById.get(runId);
@@ -5576,9 +5780,13 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
});
}
- if (result.cleared > 0) {
+ if (result.cleared > 0 || result.terminalizedRunIds.length > 0) {
logger.warn(
- { cleared: result.cleared, issueIds: result.issueIds },
+ {
+ cleared: result.cleared,
+ issueIds: result.issueIds,
+ terminalizedRunIds: result.terminalizedRunIds,
+ },
"swept stale issue lock columns",
);
}
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index c6af475e502..729b64bd36b 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -71,6 +71,7 @@ import type {
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
+import { isLiveIssueRun } from "../lib/liveIssueIds";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import {
formatTimelineWorkspaceLabel,
@@ -4438,9 +4439,10 @@ export function IssueChatThread({
const displayLiveRuns = useMemo(() => {
const deduped = new Map();
for (const run of liveRuns) {
+ if (!isLiveIssueRun(run, issueStatus)) continue;
deduped.set(run.id, run);
}
- if (activeRun) {
+ if (activeRun && isLiveIssueRun(activeRun, issueStatus)) {
deduped.set(activeRun.id, {
id: activeRun.id,
status: activeRun.status,
@@ -4471,7 +4473,7 @@ export function IssueChatThread({
});
}
return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
- }, [activeRun, liveRuns]);
+ }, [activeRun, issueStatus, liveRuns]);
const transcriptRuns = useMemo(() => {
return resolveIssueChatTranscriptRuns({
linkedRuns,
@@ -4489,8 +4491,8 @@ export function IssueChatThread({
return ids;
}, [displayLiveRuns]);
const hasActiveRun = useMemo(
- () => displayLiveRuns.some((run) => run.status === "running") || activeRun?.status === "running",
- [displayLiveRuns, activeRun],
+ () => displayLiveRuns.some((run) => run.status === "running"),
+ [displayLiveRuns],
);
// Real-time view of the handoff: a run that starts after the issue payload
// was fetched must quiet the missing-disposition warnings without waiting
@@ -4536,6 +4538,7 @@ export function IssueChatThread({
agentMap,
currentUserId,
userLabelMap,
+ issueStatus,
}),
[
comments,
@@ -4552,6 +4555,7 @@ export function IssueChatThread({
agentMap,
currentUserId,
userLabelMap,
+ issueStatus,
],
);
const stableMessagesRef = useRef([]);
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 739da913434..27bfa3fb348 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -32,6 +32,7 @@ import { useSidebar } from "@/context/SidebarContext";
import { cn } from "@/lib/utils";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { latestSameRunHandoffTimestamp, type IssueChatComment } from "@/lib/issue-chat-messages";
+import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds";
import { workModeInEffectAt } from "@/lib/issue-timeline-events";
import { workModeMetaFor } from "@/lib/work-mode-meta";
@@ -184,9 +185,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// The single in-flight run whose turn we stream live (non-terminal).
const liveRun = useMemo(() => {
- if (activeRun && !isTerminalRunStatus(activeRun.status)) return activeRun;
- return (liveRuns ?? []).find((r) => !isTerminalRunStatus(r.status)) ?? null;
- }, [activeRun, liveRuns]);
+ if (isTerminalIssueStatus(issueStatus)) return null;
+ if (activeRun && isLiveIssueRun(activeRun, issueStatus)) return activeRun;
+ return (liveRuns ?? []).find((r) => isLiveIssueRun(r, issueStatus)) ?? null;
+ }, [activeRun, issueStatus, liveRuns]);
// Runs observed non-terminal while mounted: their turns ANIMATE the fold when
// they settle. Runs already terminal at mount collapse instantly.
diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts
index 365b985b7bf..169da08f5f9 100644
--- a/ui/src/lib/issue-chat-messages.test.ts
+++ b/ui/src/lib/issue-chat-messages.test.ts
@@ -624,6 +624,44 @@ describe("buildIssueChatMessages", () => {
});
});
+ it("suppresses live-run Working messages for terminal issues", () => {
+ const liveRun: LiveRunForIssue = {
+ id: "run-live-terminal",
+ status: "running",
+ invocationSource: "manual",
+ triggerDetail: null,
+ startedAt: "2026-04-06T12:04:00.000Z",
+ finishedAt: null,
+ createdAt: "2026-04-06T12:04:00.000Z",
+ agentId: "agent-1",
+ agentName: "CodexCoder",
+ adapterType: "codex_local",
+ };
+
+ const terminalMessages = buildIssueChatMessages({
+ comments: [],
+ timelineEvents: [],
+ linkedRuns: [],
+ liveRuns: [liveRun],
+ issueStatus: "done",
+ currentUserId: "user-1",
+ });
+ const liveMessages = buildIssueChatMessages({
+ comments: [],
+ timelineEvents: [],
+ linkedRuns: [],
+ liveRuns: [liveRun],
+ issueStatus: "in_progress",
+ currentUserId: "user-1",
+ });
+
+ expect(terminalMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toBeUndefined();
+ expect(liveMessages.find((message) => message.id === "run-assistant:run-live-terminal")).toMatchObject({
+ status: { type: "running" },
+ metadata: { custom: { waitingText: "Working..." } },
+ });
+ });
+
it("merges thread interactions into the same chronological feed as comments and runs", () => {
const messages = buildIssueChatMessages({
comments: [
diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts
index 72462011834..84ccc7cb171 100644
--- a/ui/src/lib/issue-chat-messages.ts
+++ b/ui/src/lib/issue-chat-messages.ts
@@ -16,6 +16,7 @@ import {
type IssueThreadInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
+import { isLiveIssueRun } from "./liveIssueIds";
import {
summarizeNotice,
} from "./transcriptPresentation";
@@ -954,13 +955,15 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
function normalizeLiveRuns(
liveRuns: readonly LiveRunForIssue[],
activeRun: ActiveRunForIssue | null | undefined,
- issueId?: string,
+ issueId: string | undefined,
+ issueStatus: string | null | undefined,
) {
const deduped = new Map();
for (const run of liveRuns) {
+ if (!isLiveIssueRun(run, issueStatus)) continue;
deduped.set(run.id, run);
}
- if (activeRun) {
+ if (activeRun && isLiveIssueRun(activeRun, issueStatus)) {
deduped.set(activeRun.id, {
id: activeRun.id,
status: activeRun.status,
@@ -1056,6 +1059,7 @@ export function buildIssueChatMessages(args: {
agentMap?: Map;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap | null;
+ issueStatus?: string | null;
}) {
const {
comments,
@@ -1073,6 +1077,7 @@ export function buildIssueChatMessages(args: {
agentMap,
currentUserId,
userLabelMap,
+ issueStatus,
} = args;
const orderedMessages: MessageWithOrder[] = [];
@@ -1139,7 +1144,7 @@ export function buildIssueChatMessages(args: {
});
}
- for (const run of normalizeLiveRuns(liveRuns, activeRun, issueId)) {
+ for (const run of normalizeLiveRuns(liveRuns, activeRun, issueId, issueStatus)) {
orderedMessages.push({
createdAtMs: toTimestamp(run.startedAt ?? run.createdAt),
order: 3,
diff --git a/ui/src/lib/liveIssueIds.test.ts b/ui/src/lib/liveIssueIds.test.ts
index aa9a652b8d6..06bd8e025c3 100644
--- a/ui/src/lib/liveIssueIds.test.ts
+++ b/ui/src/lib/liveIssueIds.test.ts
@@ -2,6 +2,23 @@ import { describe, expect, it } from "vitest";
import type { LiveRunForIssue } from "../api/heartbeats";
import { collectLiveIssueIds, collectSubtreeLiveCounts } from "./liveIssueIds";
+function liveRun(overrides: Partial): LiveRunForIssue {
+ return {
+ id: "run",
+ status: "running",
+ invocationSource: "scheduler",
+ triggerDetail: null,
+ startedAt: "2026-04-20T10:00:00.000Z",
+ finishedAt: null,
+ createdAt: "2026-04-20T10:00:00.000Z",
+ agentId: "agent",
+ agentName: "Agent",
+ adapterType: "codex_local",
+ issueId: "issue",
+ ...overrides,
+ };
+}
+
describe("collectLiveIssueIds", () => {
it("keeps only runs linked to issues", () => {
const liveRuns: LiveRunForIssue[] = [
@@ -74,6 +91,72 @@ describe("collectLiveIssueIds", () => {
expect([...collectLiveIssueIds(liveRuns)]).toEqual(["issue-1", "issue-2"]);
});
+
+ it("suppresses live ids for terminal issues while keeping non-terminal issues live", () => {
+ const liveRuns: LiveRunForIssue[] = [
+ {
+ id: "run-terminal",
+ status: "running",
+ invocationSource: "scheduler",
+ triggerDetail: null,
+ startedAt: "2026-04-20T10:00:00.000Z",
+ finishedAt: null,
+ createdAt: "2026-04-20T10:00:00.000Z",
+ agentId: "agent-1",
+ agentName: "Coder",
+ adapterType: "codex_local",
+ issueId: "issue-done",
+ },
+ {
+ id: "run-live",
+ status: "queued",
+ invocationSource: "scheduler",
+ triggerDetail: null,
+ startedAt: null,
+ finishedAt: null,
+ createdAt: "2026-04-20T10:01:00.000Z",
+ agentId: "agent-2",
+ agentName: "Builder",
+ adapterType: "codex_local",
+ issueId: "issue-open",
+ },
+ ];
+
+ expect([...collectLiveIssueIds(liveRuns, [
+ { id: "issue-done", status: "done" },
+ { id: "issue-open", status: "in_progress" },
+ ])]).toEqual(["issue-open"]);
+ });
+
+ it("keeps newer terminal snapshots authoritative when stale non-terminal snapshots appear later", () => {
+ const liveRuns: LiveRunForIssue[] = [
+ liveRun({ id: "run-done", issueId: "issue-done", status: "running" }),
+ liveRun({ id: "run-cancelled", issueId: "issue-cancelled", status: "queued" }),
+ liveRun({ id: "run-open", issueId: "issue-open", status: "running" }),
+ ];
+
+ expect([...collectLiveIssueIds(liveRuns, [
+ { id: "issue-done", status: "done", updatedAt: "2026-04-20T10:02:00.000Z" },
+ { id: "issue-cancelled", status: "cancelled", updatedAt: "2026-04-20T10:02:00.000Z" },
+ { id: "issue-open", status: "in_progress", updatedAt: "2026-04-20T10:02:00.000Z" },
+ { id: "issue-done", status: "in_progress", updatedAt: "2026-04-20T10:01:00.000Z" },
+ { id: "issue-cancelled", status: "todo", updatedAt: "2026-04-20T10:01:00.000Z" },
+ ])]).toEqual(["issue-open"]);
+ });
+
+ it("allows a newer non-terminal snapshot to reopen an issue with a stale terminal snapshot", () => {
+ const liveRuns: LiveRunForIssue[] = [
+ liveRun({ id: "run-reopened", issueId: "issue-reopened", status: "running" }),
+ liveRun({ id: "run-terminal", issueId: "issue-terminal", status: "queued" }),
+ ];
+
+ expect([...collectLiveIssueIds(liveRuns, [
+ { id: "issue-reopened", status: "done", updatedAt: "2026-04-20T10:01:00.000Z" },
+ { id: "issue-terminal", status: "in_progress", updatedAt: "2026-04-20T10:01:00.000Z" },
+ { id: "issue-reopened", status: "in_progress", updatedAt: "2026-04-20T10:02:00.000Z" },
+ { id: "issue-terminal", status: "done", updatedAt: "2026-04-20T10:02:00.000Z" },
+ ])]).toEqual(["issue-reopened"]);
+ });
});
describe("collectSubtreeLiveCounts", () => {
diff --git a/ui/src/lib/liveIssueIds.ts b/ui/src/lib/liveIssueIds.ts
index 07f4a75222e..a8c83ff9de7 100644
--- a/ui/src/lib/liveIssueIds.ts
+++ b/ui/src/lib/liveIssueIds.ts
@@ -1,13 +1,71 @@
+import type { IssueStatus } from "@paperclipai/shared";
import type { LiveRunForIssue } from "../api/heartbeats";
function isLiveRunStatus(status: string): boolean {
return status === "queued" || status === "running";
}
-export function collectLiveIssueIds(liveRuns: readonly LiveRunForIssue[] | null | undefined): Set {
+const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]);
+
+export function isTerminalIssueStatus(status: string | null | undefined): status is IssueStatus {
+ return TERMINAL_ISSUE_STATUSES.has(status as IssueStatus);
+}
+
+export function isLiveIssueRun(
+ run: Pick,
+ issueStatus?: string | null,
+): boolean {
+ return isLiveRunStatus(run.status) && !isTerminalIssueStatus(issueStatus);
+}
+
+export interface LiveIssueStatusNode {
+ id: string;
+ status: IssueStatus | string;
+ updatedAt?: Date | string | number | null;
+}
+
+function collectIssueStatusById(issues: readonly LiveIssueStatusNode[] | null | undefined): Map {
+ const snapshotByIssueId = new Map();
+ for (const issue of issues ?? []) {
+ const candidate = {
+ status: issue.status,
+ updatedAtMs: issueUpdatedAtMs(issue.updatedAt),
+ };
+ const existing = snapshotByIssueId.get(issue.id);
+ if (!existing || shouldReplaceIssueStatusSnapshot(existing, candidate)) snapshotByIssueId.set(issue.id, candidate);
+ }
+ return new Map([...snapshotByIssueId].map(([issueId, snapshot]) => [issueId, snapshot.status]));
+}
+
+function issueUpdatedAtMs(updatedAt: LiveIssueStatusNode["updatedAt"]): number | null {
+ if (updatedAt === null || updatedAt === undefined) return null;
+ const timestamp = updatedAt instanceof Date ? updatedAt.getTime() : new Date(updatedAt).getTime();
+ return Number.isFinite(timestamp) ? timestamp : null;
+}
+
+function shouldReplaceIssueStatusSnapshot(
+ existing: { status: string; updatedAtMs: number | null },
+ candidate: { status: string; updatedAtMs: number | null },
+): boolean {
+ if (candidate.updatedAtMs !== null && existing.updatedAtMs !== null) {
+ if (candidate.updatedAtMs !== existing.updatedAtMs) return candidate.updatedAtMs > existing.updatedAtMs;
+ } else if (candidate.updatedAtMs !== null) {
+ return true;
+ } else if (existing.updatedAtMs !== null) {
+ return false;
+ }
+
+ return !isTerminalIssueStatus(existing.status) && isTerminalIssueStatus(candidate.status);
+}
+
+export function collectLiveIssueIds(
+ liveRuns: readonly LiveRunForIssue[] | null | undefined,
+ issues?: readonly LiveIssueStatusNode[] | null,
+): Set {
const ids = new Set();
+ const statusByIssueId = collectIssueStatusById(issues);
for (const run of liveRuns ?? []) {
- if (run.issueId && isLiveRunStatus(run.status)) ids.add(run.issueId);
+ if (run.issueId && isLiveIssueRun(run, statusByIssueId.get(run.issueId))) ids.add(run.issueId);
}
return ids;
}
diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx
index fa2b5a3abab..2cc119a5422 100644
--- a/ui/src/pages/ExecutionWorkspaceDetail.tsx
+++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx
@@ -542,7 +542,7 @@ function ExecutionWorkspaceIssuesList({
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
- const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
+ const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record }) => issuesApi.update(id, data),
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index c86873fe70d..e9abc46966e 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -945,7 +945,6 @@ export function Inbox() {
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
- const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
@@ -1001,6 +1000,15 @@ export function Inbox() {
enabled: shouldUseIssueSearchSupplement,
placeholderData: (previousData) => previousData,
});
+ const liveIssueIds = useMemo(
+ () => collectLiveIssueIds(liveRuns, [
+ ...(issues ?? []),
+ ...mineIssuesRaw,
+ ...touchedIssuesRaw,
+ ...remoteIssueSearchResults,
+ ]),
+ [issues, liveRuns, mineIssuesRaw, remoteIssueSearchResults, touchedIssuesRaw],
+ );
const inboxIssueIdsForExternalObjectSummaries = useMemo(() => {
const issueIds = new Set();
for (const issue of mineIssues) issueIds.add(issue.id);
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 1c632986e40..f599c75365b 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -1980,7 +1980,10 @@ export function IssueDetail() {
},
[issue?.id, rawChildIssues],
);
- const liveIssueIds = useMemo(() => collectLiveIssueIds(companyLiveRuns), [companyLiveRuns]);
+ const liveIssueIds = useMemo(
+ () => collectLiveIssueIds(companyLiveRuns, issue ? [issue, ...childIssues] : childIssues),
+ [childIssues, companyLiveRuns, issue],
+ );
const issuePanelKey = useMemo(
() => buildIssuePropertiesPanelKey(issue ?? null, childIssues),
[childIssues, issue],
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx
index 0a2ce34f521..02bcddd5f18 100644
--- a/ui/src/pages/Issues.tsx
+++ b/ui/src/pages/Issues.tsx
@@ -115,8 +115,6 @@ export function Issues() {
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
- const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
-
const issueLinkState = useMemo(
() =>
createIssueDetailLocationState(
@@ -169,6 +167,7 @@ export function Issues() {
});
const issues = useMemo(() => mergeIssuePagesStable(issuePages?.pages ?? []) as Issue[], [issuePages]);
+ const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
const hasMoreServerIssues = syncedSearch.trim().length === 0
&& hasNextPage === true;
const loadMoreServerIssues = useCallback(() => {
diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx
index 5a0fbe47db8..7041a4a7926 100644
--- a/ui/src/pages/ProjectDetail.tsx
+++ b/ui/src/pages/ProjectDetail.tsx
@@ -259,13 +259,12 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
enabled: !!companyId,
});
- const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
-
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listByProject(companyId, projectId),
queryFn: () => issuesApi.list(companyId, { projectId }),
enabled: !!companyId,
});
+ const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record }) =>
@@ -330,13 +329,12 @@ function ProjectPluginOperationsList({
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
- const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
-
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }),
enabled: !!companyId && !!projectId,
});
+ const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record }) =>
diff --git a/ui/src/pages/Routines.tsx b/ui/src/pages/Routines.tsx
index 213396320ed..4ec6f105038 100644
--- a/ui/src/pages/Routines.tsx
+++ b/ui/src/pages/Routines.tsx
@@ -655,7 +655,7 @@ export function Routines() {
() => new Map((routineFolders?.folders ?? []).map((folder) => [folder.id, folder])),
[routineFolders],
);
- const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
+ const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, routineExecutionIssues), [liveRuns, routineExecutionIssues]);
const visibleRoutines = useMemo(
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
[routines],
diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts
index 819287024df..81800a041c4 100644
--- a/ui/src/plugins/bridge-init.ts
+++ b/ui/src/plugins/bridge-init.ts
@@ -289,13 +289,12 @@ function PluginSdkIssuesList({
refetchInterval: sharedLiveRuns.refetchInterval,
});
usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt);
- const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
-
const { data: issues, isLoading, error } = useQuery({
queryKey: issuesQueryKey,
queryFn: () => issuesApi.list(companyId!, issueFilters),
enabled: !!companyId,
});
+ const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns, issues), [issues, liveRuns]);
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record }) =>
From 9abb600e721059ec2d36aff73ed940ace4a98da8 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 7 Aug 2026 10:52:04 -0500
Subject: [PATCH 002/201] docs(connections): MCP-direct/DCR playbook section +
Notion dry-run appendix (#11030)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Apps give agents governed access to external tools through catalog
connectors.
> - The connector playbook is the repeatable template for adding such
connectors.
> - Notion just shipped as the first MCP-direct connector with RFC 7591
dynamic client registration (#11009).
> - The playbook had no guidance for MCP-direct connections, OAuth
discovery, or DCR.
> - This pull request documents that path and encodes three mandatory
standards into the connector template.
> - It also adds a Notion dry-run appendix recorded from the live probe
and the shipped implementation.
> - The benefit is that the next MCP-direct connector follows a
recorded, verified path.
> [!IMPORTANT]
> **Depends on #11009.** This PR documents the Notion MCP connector that
ships in #11009 (RFC 7591 DCR, discovery-first endpoints,
`redirectConstraints` enforcement, refresh-rotation hardening).
Reviewing this doc against master before #11009 merges will show the
documented behavior as "not implemented" — that is expected. Draft until
#11009 lands, then re-review.
## What Changed
`doc/connections/CONNECTOR-PLAYBOOK.md` only (+364 lines, no code):
- New **"MCP-Direct Connections"** section: RFC 9728/8414 endpoint
discovery chain, an **RFC 7591 dynamic client registration** subsection
(public client, PKCE S256, env-client precedence, persist-and-reuse),
and a **redirect-URI constraints** subsection (`https-or-loopback-http`,
fail-fast wizard error).
- Three mandatory documentation standards encoded into the connector
template itself: a service-involvement statement (DCR providers need
neither Paperclip ID nor Paperclip Connect — instance-local per the
PAP-14828 spec §10 item 8.4; cloud and self-hosted use the same path), a
required **Connection Flow** section (sequence diagram + exact
authorize/token/registration/callback endpoints), and a required
**Administrator Setup** section.
- A **Notion dry-run appendix** mirroring the Linear appendix, recorded
from the live PAP-16649 probe: verified request sequence, redirect-URI
probe results table, sequence diagram (mermaid), shipped manifest
sketch, representative tool risk classes, admin setup (nothing to
register), governance defaults, and validation hooks.
## Verification
- Docs-only change; no code paths affected. `git diff --stat` shows
exactly one file.
- Every endpoint, error code, and constraint in the appendix was checked
against the shipped implementation on the #11009 head:
`server/src/services/tool-access.ts` (`assertOAuthRedirectConstraints`,
DCR registration metadata, refresh serialization),
`server/src/routes/tool-access.ts` (`POST
/api/tools/oauth/:connectionId/start`, `GET /api/tools/oauth/callback`),
and `packages/shared/src/app-definitions/notion.json`
(`redirectConstraints: "https-or-loopback-http"`).
- The request log mirrors the live probe record from PAP-16649
(2026-08-06/07), not vendor docs alone.
- Mermaid source renders cleanly (rendered PNG attached to PAP-16653).
## Risks
- Low: documentation only. Main risk is doc/implementation drift if
#11009 changes before merging — mitigated by the dependency note above
and re-review after #11009 lands.
- The template changes add mandatory sections for future connector
proposals; existing proposals are not retroactively invalidated.
## Model Used
Claude Fable 5 (claude-fable-5).
## Linked Issues or Issue Description
PAP-16653 (parent PAP-16637 P5). Companion catalog package landed in
paperclip-content (`integrations/catalog/platforms/notion/areas/mcp/`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5
---
doc/connections/CONNECTOR-PLAYBOOK.md | 34 +++++++++++++++++++++++----
1 file changed, 29 insertions(+), 5 deletions(-)
diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md
index d1f94027983..28b57f519da 100644
--- a/doc/connections/CONNECTOR-PLAYBOOK.md
+++ b/doc/connections/CONNECTOR-PLAYBOOK.md
@@ -199,10 +199,31 @@ most; the broker resolves endpoints at connect time:
dynamic registration — `registration_endpoint`.
The broker implements this in `discoverOAuthEndpoints`
-(`server/src/services/tool-access.ts`); connections with transport
-`mcp_remote` and `auth: "oauth"` prefer discovered endpoints over manifest
-hints. Keep manifest hints current anyway so reviewers can read the expected
-endpoints without running discovery.
+(`server/src/services/tool-access.ts`), but discovery is **not**
+unconditional. `oauthEndpointsForConnection` resolves endpoints in this
+order:
+
+1. If the manifest's method `defaults` ship a **complete** pair
+ (`authorizationEndpoint` **and** `tokenEndpoint`), those are used
+ unconditionally. `discoverOAuthEndpoints` never runs in this case, so
+ endpoints stored on the connection's own OAuth config and 401 challenge
+ hints are **not consulted at all**.
+2. Otherwise, for `mcp_remote` connections, the broker calls
+ `discoverOAuthEndpoints`, which first checks endpoints already stored on
+ the connection's own OAuth config (falling back field-by-field to the 401
+ challenge hints); a complete stored/hinted pair is used as-is — no
+ `.well-known` fetch.
+3. Only when neither of the above yields a complete pair does the broker run
+ the RFC 9728 → RFC 8414 discovery chain above.
+
+Consequence: complete manifest endpoint hints are **authoritative, not
+hints** — they override even endpoints that an earlier discovery persisted
+on the connection, and if they go stale the broker keeps using them. For
+discovery-capable vendors, ship only `serverUrl` in `defaults` (as
+`notion.json` does) so the broker discovers fresh endpoints at connect
+time; add explicit `authorizationEndpoint`/`tokenEndpoint` only for vendors
+that do not publish RFC 9728/8414 metadata, and then own keeping them
+current.
### Dynamic client registration (RFC 7591)
@@ -497,7 +518,10 @@ alone.
- Transport: `mcp_remote`
- Endpoint: `https://mcp.notion.com/mcp` (Streamable HTTP; `/sse` fallback exists)
- Auth mode: OAuth, endpoints resolved by discovery (RFC 9728 → RFC 8414),
- public client via RFC 7591 DCR with PKCE S256 mandatory.
+ public client via RFC 7591 DCR with PKCE S256 mandatory. Discovery runs
+ because `notion.json` deliberately ships only `serverUrl` — no
+ `authorizationEndpoint`/`tokenEndpoint` hints, which would otherwise take
+ precedence and be used verbatim (see "MCP-Direct Connections" above).
- Ownership modes: `dcr` (default, zero setup) and `customer`
(env-registered classic integration via
`PAPERCLIP_TOOL_OAUTH_NOTION_CLIENT_ID/_SECRET`, which always wins when set).
From 42c73562c5c18cb4e01d60f3d89295ff13992a36 Mon Sep 17 00:00:00 2001
From: Michael Nguyen <13559011+nguyenm7@users.noreply.github.com>
Date: Fri, 7 Aug 2026 09:06:36 -0700
Subject: [PATCH 003/201] fix(heartbeat): backfill projectWorkspaceId when
restoring a reused execution workspace (#10171)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Each run executes inside a persisted **execution workspace** (a row
in `execution_workspaces`) that is either freshly created or
**restored/reused** across runs of the same issue
> - Before adapter launch, a guard rejects a restored workspace whose
`projectWorkspaceId` is null while the issue resolves a concrete project
workspace (`persisted_workspace_missing_project_workspace_id`) — a
safety check against binding a run to a workspace with no
project-workspace link
> - The reuse/**restore** path updated the existing row (cwd, branch,
status, metadata…) but **never set `projectWorkspaceId`**, so a row
persisted with a null value stayed null on every restore
> - Result: for an issue that resolves a project workspace, the guard
fires, `reuse_existing` re-selects and re-binds the *same* stale null
row on the next attempt, and the run crash-loops forever with no
self-heal
> - This pull request backfills `projectWorkspaceId` during restore
(prefer the existing binding, fall back to the resolved one) so the row
heals on first reuse and the guard stops firing
> - The benefit is that reused workspaces created before their project
had a primary project workspace self-repair on next use instead of
crash-looping, while genuine mismatches are still surfaced by the guard
## Linked Issues or Issue Description
No public GitHub issue exists — describing the bug inline per the bug
report template (`.github/ISSUE_TEMPLATE/bug_report.yml`):
### What happened?
In `heartbeatService`, the execution-workspace reuse/restore branch
calls
`executionWorkspacesSvc.update(reusableExistingExecutionWorkspace.id, {
… })` without a `projectWorkspaceId` field. Only the sibling CREATE
branch sets `projectWorkspaceId`. So an execution workspace that was
persisted with a null `projectWorkspaceId` (e.g. created before its
project had a primary project workspace) is never backfilled on restore.
When such a workspace is later reused for a run whose issue resolves a
concrete project workspace, the pre-launch guard throws
`persisted_workspace_missing_project_workspace_id`, the run fails, and
`reuse_existing` re-binds the identical stale row on the next attempt —
an unbounded crash-loop with no self-heal.
### Expected behavior
On restore, the reused workspace's `projectWorkspaceId` is backfilled
from the resolved project workspace when it is currently null, so the
guard passes and the run launches. An existing non-null binding is never
overwritten (a genuine mismatch is still surfaced by the separate
`project_workspace_mismatch` guard).
### Steps to reproduce
1. Have an `execution_workspaces` row with `project_workspace_id = NULL`
that is eligible for reuse.
2. Give its project a primary project workspace (so the issue now
resolves a concrete `projectWorkspaceId`).
3. Dispatch a run for an issue in that project that reuses the
workspace. The restore `update()` leaves `project_workspace_id` null,
the launch guard throws
`persisted_workspace_missing_project_workspace_id`, and every subsequent
reuse re-binds the same null row and fails identically.
### Paperclip version or commit
`master` (branched from `14f20be92`); reproduced on a live self-hosted
instance.
### Deployment mode
Self-hosted, embedded Postgres, local adapters.
## What Changed
- New exported pure helper
`reconcileReusedExecutionWorkspaceProjectWorkspaceId(existing,
resolved)` in `server/src/services/heartbeat.ts`, returning `existing ??
resolved ?? null`. It prefers an existing binding (never nulls out a
good value or silently rebinds a genuine mismatch — the guard still
surfaces those), backfills a null binding from the resolved value, and
stays null when neither is present.
- Wire the helper into the reuse/restore
`executionWorkspacesSvc.update(...)` call so the restored row's
`projectWorkspaceId` is set to
`reconcileReusedExecutionWorkspaceProjectWorkspaceId(reusableExistingExecutionWorkspace.projectWorkspaceId,
resolvedProjectWorkspaceId)`. The CREATE branch already set
`projectWorkspaceId`; this brings the restore branch to parity.
## Verification
- Added 3-case unit coverage in
`server/src/__tests__/heartbeat-workspace-session.test.ts` for the
helper: (a) backfills a null existing binding from the resolved value,
(b) never overwrites an existing binding even when a resolved value is
present, (c) returns null when both existing and resolved are absent
(null and undefined inputs).
- Confirmed the `update()` patch type accepts the field:
`executionWorkspacesSvc.update` takes `Partial`, and `projectWorkspaceId` is a column
on that table; both
`reusableExistingExecutionWorkspace.projectWorkspaceId` and
`resolvedProjectWorkspaceId` are `string | null`, matching the helper's
`string | null | undefined` params / `string | null` return.
- Live-instance exposure check (embedded Postgres): 354
`execution_workspaces` rows carry a null `project_workspace_id`; all of
them belong to projects with **no** project workspace, so
`expectedProjectWorkspaceId` currently resolves null and the guard does
not fire today. The fix is durable heal-on-reuse protection for the
moment any such project gains a primary project workspace (or a null row
is reused for an issue that resolves one).
- CI (full pnpm workspace install) runs the authoritative test +
typecheck for this change on this PR.
## Risks
- Low risk; scoped to the execution-workspace restore path, no schema or
API change.
- The helper only ever *adds* a `projectWorkspaceId` where the row had
none; it never overwrites an existing binding, so it cannot mask a real
`project_workspace_mismatch` (that guard still runs after).
- Complementary to (not overlapping with) #10130, which escalates a
terminal `workspace_validation_failed` run to `blocked` from the
recovery side; this PR prevents the guard from firing on reuse in the
first place. Neither depends on the other.
## Model Used
Claude Opus 4.8 (`claude-opus-4-8`), extended thinking, with tool use /
code execution (repo edit, embedded-Postgres exposure query, unit-logic
verification).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above (searched open PRs touching heartbeat / execution-workspace /
reuse; only #10130 is related, and it is complementary)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes (no
user-facing docs affected)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (in progress)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending)
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Paperclip
---
.../heartbeat-workspace-session.test.ts | 27 +++++++++++++++++++
server/src/services/heartbeat.ts | 24 +++++++++++++++++
2 files changed, 51 insertions(+)
diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts
index 26734c687dd..a9693828626 100644
--- a/server/src/__tests__/heartbeat-workspace-session.test.ts
+++ b/server/src/__tests__/heartbeat-workspace-session.test.ts
@@ -25,6 +25,7 @@ import {
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
provisionExecutionWorkspaceForFreshnessDecision,
+ reconcileReusedExecutionWorkspaceProjectWorkspaceId,
resolveExecutionWorkspaceConfigFreshness,
resolveExecutionWorkspaceReuseRequestForIssue,
resolveExecutionWorkspaceReuseProvisioningPolicy,
@@ -2884,3 +2885,29 @@ describe("isWorkspaceSyncConflictFailure", () => {
expect(isWorkspaceSyncConflictFailure("")).toBe(false);
});
});
+
+describe("reconcileReusedExecutionWorkspaceProjectWorkspaceId", () => {
+ it("backfills a null existing binding from the resolved value", () => {
+ expect(
+ reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, "resolved-workspace"),
+ ).toBe("resolved-workspace");
+ });
+
+ it("never overwrites an existing binding, even when a resolved value is present", () => {
+ expect(
+ reconcileReusedExecutionWorkspaceProjectWorkspaceId("existing-workspace", "resolved-workspace"),
+ ).toBe("existing-workspace");
+ });
+
+ it("returns null when both existing and resolved are absent", () => {
+ expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, null)).toBeNull();
+ expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, undefined)).toBeNull();
+ expect(reconcileReusedExecutionWorkspaceProjectWorkspaceId(null, undefined)).toBeNull();
+ });
+
+ it("backfills when existing is undefined and resolved is present", () => {
+ expect(
+ reconcileReusedExecutionWorkspaceProjectWorkspaceId(undefined, "resolved-workspace"),
+ ).toBe("resolved-workspace");
+ });
+});
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index 11e9e247255..f9eddcbd5b8 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -1979,6 +1979,26 @@ export async function assertPushCapabilityCheckoutValid(input: {
);
}
+/**
+ * Reconcile the `projectWorkspaceId` for a reused execution workspace.
+ *
+ * A `reuse_existing` workspace can have been persisted with a null
+ * `projectWorkspaceId` (e.g. it was created before its project had a primary
+ * project workspace). When we later restore it for a run whose issue now
+ * expects a concrete project workspace, backfill the column so the launch
+ * guard (`persisted_workspace_missing_project_workspace_id`) stops rejecting
+ * it on every requeue — otherwise `reuse_existing` re-binds the same stale
+ * record forever and the run crash-loops. Prefer the existing binding when
+ * present so we never null out a good value or silently rebind a genuine
+ * mismatch (which the guard still surfaces).
+ */
+export function reconcileReusedExecutionWorkspaceProjectWorkspaceId(
+ existingProjectWorkspaceId: string | null | undefined,
+ resolvedProjectWorkspaceId: string | null | undefined,
+): string | null {
+ return existingProjectWorkspaceId ?? resolvedProjectWorkspaceId ?? null;
+}
+
export async function assertGitSensitiveAdapterWorkspaceValid(input: {
adapterType: string;
agentId: string;
@@ -14578,6 +14598,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
status: "active",
lastUsedAt: new Date(),
metadata: nextExecutionWorkspaceMetadata,
+ projectWorkspaceId: reconcileReusedExecutionWorkspaceProjectWorkspaceId(
+ reusableExistingExecutionWorkspace.projectWorkspaceId,
+ resolvedProjectWorkspaceId,
+ ),
})
: resolvedProjectId
? await executionWorkspacesSvc.create({
From b18b0fc39bb35b8596e6f2418a8100317e3ab5fe Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 7 Aug 2026 14:26:40 -0500
Subject: [PATCH 004/201] feat: refine app connections and legacy worktree
startup (#11040)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Apps UI manages app discovery and app connections.
> - The managed worktree runtime starts agent work in repository
worktrees.
> - The Apps routes do not match the main discovery flow, and the
connections view lacks a delete action.
> - Legacy managed worktrees can also start before their pending seed
operation runs.
> - This pull request makes app discovery the main Apps route and makes
connection management explicit.
> - It also seeds legacy managed worktrees before runtime startup and
makes the CLI read the repository-local config.
> - The benefit is a clearer Apps workflow and a safer managed-worktree
startup path.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
This improves the Apps navigation, app connection management, managed
git-worktree startup, and CLI worktree selection.
**Subsystem affected**
Cross-cutting. The change affects `ui/`, `server/`, `cli/`, and
development documentation.
**Current behavior**
The `/apps` route opens the connections list while discovery uses a
nested route. The connections list has no delete action. Some legacy
managed worktrees can start runtime work before their pending seed
operation runs. The CLI can also read an ambient Paperclip config
instead of the repository-local config.
**Proposed behavior**
The `/apps` route opens Browse, and `/apps/connections` opens the
connection list. Users can delete a connection after confirmation.
Runtime startup seeds legacy managed worktrees when required. The CLI
resolves the current worktree from the repository-local
`.paperclip/config.json` file.
**Reason and benefit**
Users can discover apps from the canonical Apps route and can manage
existing connections from a dedicated route. Legacy worktrees receive
their required repository content before agent runtime starts. CLI
worktree selection stays scoped to the current repository.
**Breaking changes**
The `/apps` and `/apps/browse` route behavior changes. Old Browse links
redirect to `/apps`. The change does not modify an API schema or
database schema.
## What Changed
- Make Browse the canonical `/apps` page and move the connection list to
`/apps/connections`.
- Align Apps navigation, redirects, attention links, empty states, and
connection actions with the new routes.
- Add connection deletion with confirmation and clear failure feedback.
- Seed legacy managed git worktrees before runtime startup when their
seed status is pending.
- Read the CLI worktree selection from the repository-local Paperclip
config.
- Update focused UI, server, CLI, and development documentation
coverage.
## Verification
- Ran 202 focused UI, server, and CLI tests. All tests passed.
- Ran `pnpm -r typecheck`. All projects passed.
- Ran `pnpm build`. All projects built successfully.
- Ran `pnpm test:run`. The server and UI stages passed 7,168 tests. The
CLI stage found one environment-sensitive secrets test because this
workspace injects static AWS credentials. The isolated CLI file passed
all 8 tests after those injected variables were unset.
- Ran `pnpm check:token-gates`. It reports 12 existing color-token
violations in the unchanged `PaperclipOrbit3D.tsx` file from the target
branch. This pull request does not modify that file.
- Ran focused regression coverage for repository-root CLI config
resolution and connection deletion state. All tests and affected package
typechecks passed.
- Collected all 27 tests in the six changed Playwright specifications
successfully.
- GitHub Actions passed every latest-head CI gate, including all three
e2e shards and the aggregate `e2e` and `verify` jobs.
- Greptile reviewed the final commit at 5/5 with zero unresolved
threads.
## Risks
- Existing bookmarks for `/apps/browse` redirect to `/apps`.
- Connection deletion changes visible connection state and requires user
confirmation.
- The legacy seed path runs only for managed git worktrees with pending
seed state. Tests cover the startup condition.
- The rebase preserves the target branch's direct OAuth policy for the
Notion connection flow.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
OpenAI Codex with the GPT-5 model family assisted this change. The agent
used reasoning, repository tools, code execution, and test execution.
The runtime does not expose the exact model snapshot or context-window
size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
cli/src/__tests__/worktree.test.ts | 44 ++++++
cli/src/commands/worktree.ts | 21 +--
doc/DEVELOPING.md | 1 +
.../src/__tests__/workspace-runtime.test.ts | 36 +++++
server/src/services/workspace-runtime.ts | 32 +++-
tests/e2e/app-not-connected.spec.ts | 9 +-
tests/e2e/applications-crud.spec.ts | 8 +-
tests/e2e/apps-dark-mode-shots.spec.ts | 9 +-
tests/e2e/apps-prosumer-mcp-flow.spec.ts | 8 +-
tests/e2e/mcp-user-stories.spec.ts | 2 +-
tests/e2e/smoke-lab.spec.ts | 4 +-
ui/src/App.test.tsx | 10 ++
ui/src/App.tsx | 15 +-
.../components/AppConnectionSidebar.test.tsx | 8 +-
ui/src/components/AppConnectionSidebar.tsx | 2 +-
ui/src/components/AppsSidebar.test.tsx | 4 +-
ui/src/components/AppsSidebar.tsx | 4 +-
ui/src/components/Layout.test.tsx | 2 +-
ui/src/pages/apps/AppDetail.tsx | 6 +-
ui/src/pages/apps/AppNotConnected.tsx | 6 +-
ui/src/pages/apps/AppsConnect.tsx | 6 +-
ui/src/pages/apps/Browse.tsx | 3 +-
ui/src/pages/apps/Connections.test.tsx | 137 ++++++++++++++++++
ui/src/pages/apps/Connections.tsx | 130 +++++++++++++++--
ui/src/pages/apps/gateways/AppsSubNav.tsx | 2 +-
ui/src/pages/tools/PasteConfigTab.test.tsx | 2 +-
ui/src/pages/tools/PasteConfigTab.tsx | 2 +-
ui/src/pages/tools/ToolsAccess.test.tsx | 12 +-
ui/src/pages/tools/ToolsAccess.tsx | 2 +-
.../pages/tools/profiles/WizardToolsStep.tsx | 2 +-
30 files changed, 452 insertions(+), 77 deletions(-)
diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts
index ce063abfd79..dab7f78bfd9 100644
--- a/cli/src/__tests__/worktree.test.ts
+++ b/cli/src/__tests__/worktree.test.ts
@@ -31,6 +31,7 @@ import {
resolveWorktreeReseedTargetPaths,
resolveGitWorktreeAddArgs,
resolvePnpmInstallInvocation,
+ resolveCurrentWorktreeEndpoint,
resolveWorktreeSeedBackupEngine,
resolveWorktreeMakeTargetPath,
worktreeRepairCommand,
@@ -167,6 +168,49 @@ function buildSourceConfig(): PaperclipConfig {
}
describe("worktree helpers", () => {
+ it("uses the repo-local config for the current worktree", () => {
+ const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-"));
+ try {
+ const localConfig = path.join(targetRoot, ".paperclip", "config.json");
+ fs.mkdirSync(path.dirname(localConfig), { recursive: true });
+ fs.writeFileSync(localConfig, "{}\n");
+ process.env.PAPERCLIP_CONFIG = "/tmp/ambient-paperclip/config.json";
+ process.chdir(targetRoot);
+
+ expect(resolveCurrentWorktreeEndpoint()).toMatchObject({
+ rootPath: targetRoot,
+ configPath: localConfig,
+ isCurrent: true,
+ });
+ } finally {
+ process.chdir(ORIGINAL_CWD);
+ fs.rmSync(targetRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("uses the repository config from a nested working directory", () => {
+ const targetRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-current-worktree-nested-"));
+ try {
+ execFileSync("git", ["init", "-q"], { cwd: targetRoot });
+ const nestedDirectory = path.join(targetRoot, "packages", "example", "src");
+ const localConfig = path.join(targetRoot, ".paperclip", "config.json");
+ fs.mkdirSync(nestedDirectory, { recursive: true });
+ fs.mkdirSync(path.dirname(localConfig), { recursive: true });
+ fs.writeFileSync(localConfig, "{}\n");
+ process.env.PAPERCLIP_CONFIG = "/tmp/ambient-paperclip/config.json";
+ process.chdir(nestedDirectory);
+
+ expect(resolveCurrentWorktreeEndpoint()).toMatchObject({
+ rootPath: targetRoot,
+ configPath: localConfig,
+ isCurrent: true,
+ });
+ } finally {
+ process.chdir(ORIGINAL_CWD);
+ fs.rmSync(targetRoot, { recursive: true, force: true });
+ }
+ });
+
it("sanitizes instance ids", () => {
expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support");
expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree");
diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts
index 676ee7d52ff..fe07f3ce618 100644
--- a/cli/src/commands/worktree.ts
+++ b/cli/src/commands/worktree.ts
@@ -2218,10 +2218,13 @@ async function closeDb(db: ClosableDb): Promise {
await db.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
-function resolveCurrentEndpoint(): ResolvedWorktreeEndpoint {
+export function resolveCurrentWorktreeEndpoint(): ResolvedWorktreeEndpoint {
+ const cwd = path.resolve(process.cwd());
+ const rootPath = detectGitWorkspaceInfo(cwd)?.root ?? cwd;
+ const localConfigPath = path.join(rootPath, ".paperclip", "config.json");
return {
- rootPath: path.resolve(process.cwd()),
- configPath: resolveConfigPath(),
+ rootPath,
+ configPath: existsSync(localConfigPath) ? localConfigPath : resolveConfigPath(),
label: "current",
isCurrent: true,
};
@@ -2233,7 +2236,7 @@ function resolveAttachmentLookupStorages(input: {
}): ConfiguredStorage[] {
const orderedConfigPaths = [
input.sourceEndpoint.configPath,
- resolveCurrentEndpoint().configPath,
+ resolveCurrentWorktreeEndpoint().configPath,
input.targetEndpoint.configPath,
...toMergeSourceChoices(process.cwd())
.filter((choice) => choice.hasPaperclipConfig)
@@ -2804,7 +2807,7 @@ export async function worktreeListCommand(opts: WorktreeListOptions): Promise {
const excluded = excludeWorktreePath ? path.resolve(excludeWorktreePath) : null;
- const currentEndpoint = resolveCurrentEndpoint();
+ const currentEndpoint = resolveCurrentWorktreeEndpoint();
const choices = toMergeSourceChoices(process.cwd())
.filter((choice) => choice.hasPaperclipConfig || choice.isCurrent)
.filter((choice) => path.resolve(choice.worktree) !== excluded)
@@ -3295,7 +3298,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
const targetEndpoint = opts.to
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
- : resolveCurrentEndpoint();
+ : resolveCurrentWorktreeEndpoint();
const sourceEndpoint = opts.from
? resolveWorktreeEndpointFromSelector(opts.from, { allowCurrent: true })
: sourceArg
@@ -3400,7 +3403,7 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise {
const targetEndpoint = opts.to
? resolveWorktreeEndpointFromSelector(opts.to, { allowCurrent: true })
- : resolveCurrentEndpoint();
+ : resolveCurrentWorktreeEndpoint();
const source = resolveWorktreeReseedSource(opts);
if (path.resolve(source.configPath) === path.resolve(targetEndpoint.configPath)) {
diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md
index 79af9ef36a0..ac53ac1b4f3 100644
--- a/doc/DEVELOPING.md
+++ b/doc/DEVELOPING.md
@@ -471,6 +471,7 @@ The default `worktree init` still seeds eagerly and writes `seed-complete` immed
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: a present `seed-complete` marker or a missing `seed-pending` marker short-circuits it, so it is safe to call repeatedly and from concurrent processes. It reads the source instance from the `seed-pending` marker unless you pass `--from-config`.
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot, so `run` transparently seeds a lean worktree on first launch.
+- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the worktree is still `seed-pending`. An explicitly configured runtime provision command always takes precedence.
- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned).
**Seed-pending guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose database is still `seed-pending` and points you at the fix:
diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts
index d9726d495a8..cf052cb4372 100644
--- a/server/src/__tests__/workspace-runtime.test.ts
+++ b/server/src/__tests__/workspace-runtime.test.ts
@@ -37,6 +37,7 @@ import {
refreshRemoteTrackingBaseRef,
releaseRuntimeServicesForRun,
resetRuntimeServicesForTests,
+ resolveRuntimeProvisionCommand,
resolveWorkspaceRuntimeReadinessTimeoutSec,
resolveShell,
sanitizeRuntimeServiceBaseEnv,
@@ -380,6 +381,41 @@ describe("sanitizeRuntimeServiceBaseEnv", () => {
});
});
+describe("resolveRuntimeProvisionCommand", () => {
+ it("backfills deferred seeding for legacy managed git worktrees", async () => {
+ const baseCwd = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-"));
+ const cwd = path.join(baseCwd, "worktree");
+ try {
+ await fs.mkdir(path.join(baseCwd, "scripts"), { recursive: true });
+ await fs.writeFile(
+ path.join(baseCwd, "scripts", "provision-worktree-runtime.sh"),
+ "#!/usr/bin/env bash\n",
+ );
+ await fs.mkdir(path.join(cwd, ".paperclip"), { recursive: true });
+ await fs.writeFile(path.join(cwd, ".paperclip", "seed-pending"), "{}\n");
+ const workspace = {
+ ...buildWorkspace(cwd),
+ baseCwd,
+ strategy: "git_worktree" as const,
+ worktreePath: cwd,
+ };
+
+ expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(
+ "bash ./scripts/provision-worktree-runtime.sh",
+ );
+ expect(resolveRuntimeProvisionCommand({
+ config: { runtimeProvisionCommand: "./custom-provision.sh" },
+ workspace,
+ })).toBe("./custom-provision.sh");
+
+ await fs.writeFile(path.join(cwd, ".paperclip", "seed-complete"), "{}\n");
+ expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe("");
+ } finally {
+ await fs.rm(baseCwd, { recursive: true, force: true });
+ }
+ });
+});
+
describe("refreshRemoteTrackingBaseRef git auth", () => {
it("offers the remote URL to the provider and keeps ambient behavior when it returns null", async () => {
const { remotePath, repoRoot } = await createClonedRepoWithRemote();
diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts
index 81a18e5be2b..cb96c5f9d4d 100644
--- a/server/src/services/workspace-runtime.ts
+++ b/server/src/services/workspace-runtime.ts
@@ -4109,6 +4109,34 @@ function readRuntimeProvisionCommand(config: Record) {
).trim();
}
+export function resolveRuntimeProvisionCommand(input: {
+ config: Record;
+ workspace: RealizedExecutionWorkspace;
+}) {
+ const configuredCommand = readRuntimeProvisionCommand(input.config);
+ if (configuredCommand) return configuredCommand;
+
+ if (input.workspace.strategy !== "git_worktree") return "";
+
+ const stateDir = path.join(input.workspace.cwd, ".paperclip");
+ const pendingMarker = path.join(stateDir, "seed-pending");
+ const completeMarker = path.join(stateDir, "seed-complete");
+ const provisionScript = path.join(
+ input.workspace.baseCwd,
+ "scripts",
+ "provision-worktree-runtime.sh",
+ );
+ if (
+ !existsSync(pendingMarker)
+ || existsSync(completeMarker)
+ || !existsSync(provisionScript)
+ ) {
+ return "";
+ }
+
+ return "bash ./scripts/provision-worktree-runtime.sh";
+}
+
function runtimeProvisionWorkspaceKey(input: StartLocalRuntimeServiceInput) {
return input.executionWorkspaceId
? `execution-workspace:${input.executionWorkspaceId}`
@@ -4794,7 +4822,7 @@ export async function ensureRuntimeServicesForRun(input: {
});
const acquiredServiceIds: string[] = [];
const refs: RuntimeServiceRef[] = [];
- const runtimeProvisionCommand = readRuntimeProvisionCommand(input.config);
+ const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input);
const provisionCoordinator = createRuntimeProvisionCoordinator();
runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds);
@@ -5007,7 +5035,7 @@ export async function startRuntimeServicesForWorkspaceControl(
serviceStates: readConfiguredServiceStates(input.config),
});
const invocationId = input.invocationId ?? randomUUID();
- const runtimeProvisionCommand = readRuntimeProvisionCommand(input.config);
+ const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input);
const provisionCoordinator = createRuntimeProvisionCoordinator();
if (rawServices.length === 0 || !input.db || (!input.executionWorkspaceId && !input.workspace.workspaceId)) {
diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts
index fac657543d5..e795d03f414 100644
--- a/tests/e2e/app-not-connected.spec.ts
+++ b/tests/e2e/app-not-connected.spec.ts
@@ -86,7 +86,7 @@ test.describe.serial("not-connected app page", () => {
applicationId = body.application.id as string;
// Archive the connection (Remove app), then resurrect the application so
- // it shows on /apps as "Not connected" — the state in Dotta's screenshot.
+ // it shows on /apps/connections as "Not connected" — the state in Dotta's screenshot.
const archive = await request.delete(`/api/tool-connections/${connectionId}`);
expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true);
const revive = await request.patch(`/api/tool-applications/${applicationId}`, {
@@ -100,7 +100,7 @@ test.describe.serial("not-connected app page", () => {
});
test("not-connected row opens the app page, not the generic wizard", async ({ page }) => {
- await page.goto(`/${seed.prefix}/apps`);
+ await page.goto(`/${seed.prefix}/apps/connections`);
const row = page.locator("tbody tr", { hasText: "Bla" });
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByText("Not connected")).toBeVisible();
@@ -147,7 +147,7 @@ test.describe.serial("not-connected app page", () => {
await page.goto(`/${seed.prefix}/apps/app/${applicationId}`);
await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/${connectionId}/setup$`), { timeout: 20_000 });
- await page.goto(`/${seed.prefix}/apps`);
+ await page.goto(`/${seed.prefix}/apps/connections`);
const row = page.locator("tbody tr", { hasText: "Bla" });
await expect(row).toBeVisible({ timeout: 30_000 });
await expect(row.getByRole("button", { name: /Open|Review/ })).toBeVisible();
@@ -173,8 +173,9 @@ test.describe.serial("not-connected app page", () => {
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-04-app-page-danger.png`, fullPage: true });
await page.getByRole("button", { name: "Yes, remove it" }).click();
- await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
+ await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
await expect(page.locator("tbody tr", { hasText: "Doomed app" })).toHaveCount(0);
});
});
diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts
index 4abe456f966..073ab69c71b 100644
--- a/tests/e2e/applications-crud.spec.ts
+++ b/tests/e2e/applications-crud.spec.ts
@@ -57,7 +57,7 @@ async function createConnection(
}
async function gotoApps(page: Page, prefix: string) {
- await page.goto(`/${prefix}/apps`);
+ await page.goto(`/${prefix}/apps/connections`);
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
}
@@ -143,8 +143,9 @@ test.describe.serial("applications lifecycle", () => {
await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-connected.png`, fullPage: true });
await page.getByRole("button", { name: "Yes, remove it" }).click();
- await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
+ await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
await expect(page.locator("tbody tr", { hasText: renamed })).toHaveCount(0);
});
@@ -158,8 +159,9 @@ test.describe.serial("applications lifecycle", () => {
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-not-connected.png`, fullPage: true });
await page.getByRole("button", { name: "Yes, remove it" }).click();
- await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
+ await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
await expect(page.locator("tbody tr", { hasText: cleanAppName })).toHaveCount(0);
});
});
diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts
index cc2d789fdc4..18937694a4f 100644
--- a/tests/e2e/apps-dark-mode-shots.spec.ts
+++ b/tests/e2e/apps-dark-mode-shots.spec.ts
@@ -127,7 +127,7 @@ test.describe.serial("dark-mode Apps surfaces", () => {
test("apps list dark mode with attention banner", async ({ page }) => {
await forceDark(page);
- await page.goto(`/${seed.prefix}/apps`);
+ await page.goto(`/${seed.prefix}/apps/connections`);
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/needs attention/i).first()).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-01-apps-dark.png`, fullPage: true });
@@ -135,7 +135,7 @@ test.describe.serial("dark-mode Apps surfaces", () => {
test("attention banner dark mode", async ({ page }) => {
await forceDark(page);
- await page.goto(`/${seed.prefix}/apps`);
+ await page.goto(`/${seed.prefix}/apps/connections`);
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/app needs attention/i).first()).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-02-attention-dark.png`, fullPage: true });
@@ -166,7 +166,7 @@ test.describe.serial("dark-mode Apps surfaces", () => {
await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toBeVisible();
await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0);
// Apps section lives in the same sidebar now.
- await expect(page.locator('a[href$="/apps"]', { hasText: "Connections" })).toBeVisible();
+ await expect(page.locator('a[href$="/apps/connections"]', { hasText: "Connections" })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-05-developer-overview-dark.png`, fullPage: true });
});
@@ -183,8 +183,9 @@ test.describe.serial("dark-mode Apps surfaces", () => {
await page.getByRole("button", { name: "Remove app", exact: true }).click();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-06-danger-zone-dark.png`, fullPage: true });
await page.getByRole("button", { name: "Yes, remove it" }).click();
- await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 });
+ await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 });
await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 });
+ await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-07-after-remove-dark.png`, fullPage: true });
});
});
diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts
index 6ab439592c2..aa1cd892131 100644
--- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts
+++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts
@@ -113,11 +113,11 @@ async function startMockMcp(options: { expectedHeader?: string } = {}): Promise<
// ---- Helpers ----------------------------------------------------------------
async function gotoApps(page: Page, prefix: string) {
- await page.goto(`/${prefix}/apps`);
+ await page.goto(`/${prefix}/apps/connections`);
}
async function gotoConnect(page: Page, prefix: string) {
- await page.goto(`/${prefix}/apps/browse`);
+ await page.goto(`/${prefix}/apps`);
await expect(page.getByRole("heading", { name: "Browse" })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Connect your own tool/i }).click();
}
@@ -127,7 +127,7 @@ async function gotoAdvanced(page: Page, prefix: string) {
}
async function gotoNeedsAttention(page: Page, prefix: string) {
- await page.goto(`/${prefix}/apps`);
+ await page.goto(`/${prefix}/apps/connections`);
}
// ---- Tests ------------------------------------------------------------------
@@ -212,7 +212,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
// Verify the mock saw a tools/list call from the catalog refresh.
expect(mock.captures.some((c) => c.method === "tools/list")).toBe(true);
- // The new connection should show up on /apps.
+ // The new connection should show up on /apps/connections.
await gotoApps(page, seed.prefix);
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 15_000 });
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-apps-list.png`, fullPage: true });
diff --git a/tests/e2e/mcp-user-stories.spec.ts b/tests/e2e/mcp-user-stories.spec.ts
index 4e1de84d34f..7e5d4600cd6 100644
--- a/tests/e2e/mcp-user-stories.spec.ts
+++ b/tests/e2e/mcp-user-stories.spec.ts
@@ -403,7 +403,7 @@ test.describe.serial("MCP prod Phase 5a user-story harness", () => {
const health = await request.post(`/api/tool-connections/${connectionId}/health-check`);
expect(health.status()).toBe(502);
- await page.goto(`/${seed.prefix}/apps`);
+ await page.goto(`/${seed.prefix}/apps/connections`);
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 });
await screenshot(page, "US-8", "01-needs-attention");
diff --git a/tests/e2e/smoke-lab.spec.ts b/tests/e2e/smoke-lab.spec.ts
index 09ae358164b..1b2fed21b3c 100644
--- a/tests/e2e/smoke-lab.spec.ts
+++ b/tests/e2e/smoke-lab.spec.ts
@@ -164,7 +164,7 @@ async function navigateForEvidence(page: Page, seed: Seed, connectionId: string,
return;
}
if (scenario.uiEntryPath === "attention") {
- await page.goto(`/${seed.prefix}/apps`);
+ await page.goto(`/${seed.prefix}/apps/connections`);
await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 20_000 });
return;
}
@@ -391,7 +391,7 @@ test.describe.serial("Smoke Lab scenario catalog mirror", () => {
await request.post(`/api/tool-connections/${connection.id}/catalog/refresh`),
);
expect(refresh.quarantinedCount).toBeGreaterThan(0);
- await page.goto(`/${seed.prefix}/apps`);
+ await page.goto(`/${seed.prefix}/apps/connections`);
return `Catalog refresh quarantined ${refresh.quarantinedCount} changed entries.`;
});
diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx
index 9f7b3cd32d6..f1d6fb1c2e5 100644
--- a/ui/src/App.test.tsx
+++ b/ui/src/App.test.tsx
@@ -242,3 +242,13 @@ describe("Skill Studio routes", () => {
expect(createIndexes[1]).toBeLessThan(detailIndexes[1]!);
});
});
+
+describe("Apps routes", () => {
+ it("uses browse as the Apps landing page and gives connections a canonical URL", () => {
+ expect(appSource).toContain(' } />');
+ expect(appSource).toContain(' } />');
+ expect(appSource).toContain(' } />');
+ expect(appSource).toContain(' } />');
+ expect(appSource).toContain(' } />');
+ });
+});
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 0e309f4fa20..3a5c3873232 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -122,14 +122,15 @@ function boardRoutes() {
} />
} />
}>
- } />
- } />
+ } />
+ } />
+ } />
} />
- } />
- } />
+ } />
+ } />
} />
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
- } />
+ } />
} />
} />
} />
@@ -309,7 +310,7 @@ function boardRoutes() {
function AppsConnectEntryRoute() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
- return canEnterAppsConnect(searchParams) ? : ;
+ return canEnterAppsConnect(searchParams) ? : ;
}
function InboxRootRedirect() {
@@ -403,7 +404,7 @@ function LegacyToolsRedirect() {
function legacyToolsRedirectTarget(tab?: string) {
if (!tab) return "/apps/advanced/profiles";
- if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps";
+ if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections";
return `/apps/advanced/${tab}`;
}
diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx
index 68fe3f7c9e6..db0cb79dfb9 100644
--- a/ui/src/components/AppConnectionSidebar.test.tsx
+++ b/ui/src/components/AppConnectionSidebar.test.tsx
@@ -167,7 +167,7 @@ describe("AppConnectionSidebar", () => {
it("renders a back link and the connected app tabs (including Test)", async () => {
await renderSidebar();
- expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
+ expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
expect(container.textContent).toContain("GitHub");
expect(container.querySelectorAll("[data-to]").length).toBe(6);
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true }));
@@ -190,7 +190,7 @@ describe("AppConnectionSidebar", () => {
await renderSidebar( );
- expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
+ expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
expect(container.textContent).toContain("GitHub");
expect(mockToolsApi.getConnection).not.toHaveBeenCalled();
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/setup", label: "Setup", end: true }));
@@ -212,7 +212,7 @@ describe("AppConnectionSidebar", () => {
await renderSidebar();
expect(container.textContent).toContain("App");
- expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
+ expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
expect(container.querySelectorAll("[data-to]").length).toBe(6);
});
@@ -225,7 +225,7 @@ describe("AppConnectionSidebar", () => {
await renderSidebar( );
expect(container.textContent).toContain("App");
- expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All apps");
+ expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps");
expect(container.querySelectorAll("[data-to]").length).toBe(5);
});
});
diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx
index adc50eef97f..406ce39b034 100644
--- a/ui/src/components/AppConnectionSidebar.tsx
+++ b/ui/src/components/AppConnectionSidebar.tsx
@@ -83,7 +83,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
{
if (isMobile) setSidebarOpen(false);
}}
diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx
index 4e15eebf978..73b6d09f51c 100644
--- a/ui/src/components/AppsSidebar.test.tsx
+++ b/ui/src/components/AppsSidebar.test.tsx
@@ -128,10 +128,10 @@ describe("AppsSidebar", () => {
// Three peer consumer doors: Browse (store) · Connections · Review (PAP-13254).
expect(sidebarNavItemMock).toHaveBeenCalledWith(
- expect.objectContaining({ to: "/apps/browse", label: "Browse" }),
+ expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
- expect.objectContaining({ to: "/apps", label: "Connections", end: true }),
+ expect.objectContaining({ to: "/apps/connections", label: "Connections", end: true }),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/review", label: "Review" }),
diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx
index 09d26c78f56..1b56b7f2a4d 100644
--- a/ui/src/components/AppsSidebar.tsx
+++ b/ui/src/components/AppsSidebar.tsx
@@ -71,8 +71,8 @@ export function AppsSidebar() {
Apps
-
-
+
+
{
});
it("does not mount the Apps secondary sidebar while experimental apps are disabled", async () => {
- currentPathname = "/PAP/apps/browse";
+ currentPathname = "/PAP/apps";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false });
const root = createRoot(container);
const queryClient = new QueryClient({
diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx
index 9d1222df805..6b97afe9102 100644
--- a/ui/src/pages/apps/AppDetail.tsx
+++ b/ui/src/pages/apps/AppDetail.tsx
@@ -261,7 +261,7 @@ export function AppDetail() {
body: `${appName} no longer has access. You can connect it again any time.`,
tone: "success",
});
- navigate("/apps");
+ navigate("/apps/connections");
},
onError: (error) =>
pushToast({
@@ -325,7 +325,7 @@ export function AppDetail() {
});
if (!connectionId || !activeTab) {
- return ;
+ return ;
}
if (!selectedCompanyId) {
@@ -344,7 +344,7 @@ export function AppDetail() {
return (
We couldn't find that app.
-
navigate("/apps")}>
+ navigate("/apps/connections")}>
Back to apps
diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx
index 94fc8ee89dd..5429983dd43 100644
--- a/ui/src/pages/apps/AppNotConnected.tsx
+++ b/ui/src/pages/apps/AppNotConnected.tsx
@@ -90,7 +90,7 @@ export function AppNotConnected() {
body: `${appName} no longer shows in your apps. You can connect it again any time.`,
tone: "success",
});
- navigate("/apps");
+ navigate("/apps/connections");
},
onError: (error) => {
pushToast({
@@ -105,7 +105,7 @@ export function AppNotConnected() {
return Select a company to manage apps.
;
}
if (!applicationId || !activeTab) {
- return ;
+ return ;
}
if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
return (
@@ -119,7 +119,7 @@ export function AppNotConnected() {
return (
This app doesn’t exist anymore.
-
navigate("/apps")}>Back to apps
+
navigate("/apps/connections")}>Back to apps
);
}
diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx
index 2de37c4a241..291dd1f91fb 100644
--- a/ui/src/pages/apps/AppsConnect.tsx
+++ b/ui/src/pages/apps/AppsConnect.tsx
@@ -517,7 +517,7 @@ export function AppsConnect() {
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
: undefined
}
- onCancel={() => navigate(zapierSource ? "/apps/browse" : "/apps")}
+ onCancel={() => navigate("/apps")}
/>
)}
@@ -625,7 +625,7 @@ export function AppsConnect() {
link={linkUrl}
onLinkChange={setLinkUrl}
submitting={connectMutation.isPending}
- onBack={() => navigate("/apps/browse")}
+ onBack={() => navigate("/apps")}
onConnect={() => connectMutation.mutate(undefined)}
/>
)}
@@ -685,7 +685,7 @@ export function AppsConnect() {
access={access}
installMode={installMode}
installCount={installAgentIds.size}
- onDone={() => navigate("/apps")}
+ onDone={() => navigate("/apps/connections")}
/>
)}
diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx
index db642f1370d..2775226dad8 100644
--- a/ui/src/pages/apps/Browse.tsx
+++ b/ui/src/pages/apps/Browse.tsx
@@ -48,8 +48,7 @@ export function Browse() {
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
- { label: "Apps", href: "/apps" },
- { label: "Browse" },
+ { label: "Apps" },
]);
return () => setBreadcrumbs([]);
}, [setBreadcrumbs, selectedCompany?.name]);
diff --git a/ui/src/pages/apps/Connections.test.tsx b/ui/src/pages/apps/Connections.test.tsx
index 88a11e6b550..a4402caaa09 100644
--- a/ui/src/pages/apps/Connections.test.tsx
+++ b/ui/src/pages/apps/Connections.test.tsx
@@ -11,6 +11,8 @@ const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listAppsAttentionMock = vi.hoisted(() => vi.fn());
const listProfilesMock = vi.hoisted(() => vi.fn());
+const archiveConnectionMock = vi.hoisted(() => vi.fn());
+const pushToastMock = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
@@ -20,6 +22,7 @@ vi.mock("@/api/tools", () => ({
listConnections: (companyId: string) => listConnectionsMock(companyId),
listAppsAttention: (companyId: string) => listAppsAttentionMock(companyId),
listProfiles: (companyId: string) => listProfilesMock(companyId),
+ archiveConnection: (connectionId: string) => archiveConnectionMock(connectionId),
},
}));
@@ -41,6 +44,10 @@ vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
+vi.mock("@/context/ToastContext", () => ({
+ useToast: () => ({ pushToast: pushToastMock }),
+}));
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@@ -147,6 +154,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
listApplicationsMock.mockResolvedValue({ applications: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
listProfilesMock.mockResolvedValue({ profiles: [] });
+ archiveConnectionMock.mockResolvedValue(connection({ id: "c-deleted", status: "archived" }));
container = document.createElement("div");
document.body.appendChild(container);
});
@@ -322,4 +330,133 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(mockNavigate).toHaveBeenCalledWith("/apps/c-healthy");
});
+
+ it("deletes a connection only after trash-can confirmation", async () => {
+ listApplicationsMock.mockResolvedValue({
+ applications: [application({ id: "app-github", name: "GitHub" })],
+ });
+ listConnectionsMock.mockResolvedValue({
+ connections: [connection({ id: "c-github", applicationId: "app-github", name: "GitHub" })],
+ });
+
+ await renderApps();
+
+ const deleteButton = container.querySelector(
+ 'button[aria-label="Delete GitHub connection"]',
+ );
+ expect(deleteButton).toBeTruthy();
+
+ await act(async () => {
+ deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ expect(archiveConnectionMock).not.toHaveBeenCalled();
+ expect(document.body.textContent).toContain("Delete GitHub connection?");
+
+ const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Delete connection",
+ );
+ expect(confirmButton).toBeTruthy();
+
+ await act(async () => {
+ confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ expect(archiveConnectionMock).toHaveBeenCalledWith("c-github");
+ expect(pushToastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Connection deleted",
+ body: "GitHub is no longer available to agents. You can connect it again later.",
+ tone: "success",
+ }),
+ );
+ });
+
+ it("reports the remaining active connections after deleting one", async () => {
+ listApplicationsMock.mockResolvedValue({
+ applications: [application({ id: "app-github", name: "GitHub" })],
+ });
+ listConnectionsMock.mockResolvedValue({
+ connections: [
+ connection({ id: "c-github-primary", applicationId: "app-github", name: "GitHub" }),
+ connection({ id: "c-github-secondary", applicationId: "app-github", name: "GitHub Team" }),
+ ],
+ });
+
+ await renderApps();
+
+ const deleteButton = container.querySelector(
+ 'button[aria-label="Delete GitHub connection"]',
+ );
+ await act(async () => {
+ deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(document.body.textContent).toContain(
+ "Agents can still use GitHub through 1 other active connection.",
+ );
+
+ const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Delete connection",
+ );
+ await act(async () => {
+ confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ expect(archiveConnectionMock).toHaveBeenCalledWith("c-github-primary");
+ expect(pushToastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Connection deleted",
+ body: "GitHub still has 1 active connection available to agents.",
+ tone: "success",
+ }),
+ );
+ });
+
+ it("does not count disabled connections as available after deletion", async () => {
+ listApplicationsMock.mockResolvedValue({
+ applications: [application({ id: "app-github", name: "GitHub" })],
+ });
+ listConnectionsMock.mockResolvedValue({
+ connections: [
+ connection({ id: "c-github-primary", applicationId: "app-github", name: "GitHub" }),
+ connection({
+ id: "c-github-disabled",
+ applicationId: "app-github",
+ name: "GitHub Disabled",
+ status: "disabled",
+ enabled: false,
+ }),
+ ],
+ });
+
+ await renderApps();
+
+ const deleteButton = container.querySelector(
+ 'button[aria-label="Delete GitHub connection"]',
+ );
+ await act(async () => {
+ deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(document.body.textContent).toContain("Agents will lose access immediately.");
+ expect(document.body.textContent).not.toContain("Agents can still use GitHub");
+
+ const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Delete connection",
+ );
+ await act(async () => {
+ confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
+ expect(pushToastMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: "GitHub is no longer available to agents. You can connect it again later.",
+ }),
+ );
+ });
});
diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx
index 26763d362e5..bd875d988b7 100644
--- a/ui/src/pages/apps/Connections.tsx
+++ b/ui/src/pages/apps/Connections.tsx
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { AppWindow, ShieldAlert, ShieldQuestion } from "lucide-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { AppWindow, Loader2, ShieldAlert, ShieldQuestion, Trash2 } from "lucide-react";
import type {
ToolApplication,
ToolConnection,
@@ -13,8 +13,19 @@ import {
import { useNavigate } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
+import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
@@ -29,7 +40,7 @@ import {
import { useReviewCount } from "./useReviewCount";
import { AdvancedToolsLink } from "./store-cards";
-const BROWSE_HREF = "/apps/browse";
+const BROWSE_HREF = "/apps";
type StatusFilter = "all" | "attention";
@@ -41,6 +52,7 @@ type AppStatus = {
type AppRow = {
application: ToolApplication;
primaryConnection: ToolConnection | null;
+ agentAvailableConnectionCount: number;
status: AppStatus;
actionCount: number;
lastUsedAt: Date | string | null;
@@ -83,10 +95,17 @@ const STATUS_CLASS: Record = {
export function Connections() {
const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { pushToast } = useToast();
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const reviewCount = useReviewCount();
const [filter, setFilter] = useState("all");
+ const [connectionToDelete, setConnectionToDelete] = useState<{
+ id: string;
+ appName: string;
+ remainingConnectionCount: number;
+ } | null>(null);
useEffect(() => {
setBreadcrumbs([
@@ -118,6 +137,30 @@ export function Connections() {
enabled: !!selectedCompanyId,
});
+ const deleteConnection = useMutation({
+ mutationFn: (target: { id: string; appName: string; remainingConnectionCount: number }) =>
+ toolsApi.archiveConnection(target.id),
+ onSuccess: (_connection, target) => {
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.tools.applications(selectedCompanyId!) });
+ queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
+ pushToast({
+ title: "Connection deleted",
+ body: target.remainingConnectionCount > 0
+ ? `${target.appName} still has ${target.remainingConnectionCount} active ${target.remainingConnectionCount === 1 ? "connection" : "connections"} available to agents.`
+ : `${target.appName} is no longer available to agents. You can connect it again later.`,
+ tone: "success",
+ });
+ setConnectionToDelete(null);
+ },
+ onError: (error) =>
+ pushToast({
+ title: "Couldn't delete the connection",
+ body: error instanceof Error ? error.message : "Please try again.",
+ tone: "error",
+ }),
+ });
+
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
const logoByName = useMemo(() => {
const map = new Map();
@@ -175,6 +218,9 @@ export function Connections() {
return {
application,
primaryConnection,
+ agentAvailableConnectionCount: appConnections.filter(
+ (connection) => connection.status === "active" && connection.enabled,
+ ).length,
status: statusFor(application, appConnections),
actionCount,
lastUsedAt,
@@ -344,16 +390,40 @@ export function Connections() {
- {
- event.stopPropagation();
- navigate(appHref);
- }}
- >
- {actionLabel}
-
+
+ {
+ event.stopPropagation();
+ navigate(appHref);
+ }}
+ >
+ {actionLabel}
+
+ {primaryConnection && (
+ {
+ event.stopPropagation();
+ setConnectionToDelete({
+ id: primaryConnection.id,
+ appName: application.name,
+ remainingConnectionCount: Math.max(
+ 0,
+ row.agentAvailableConnectionCount -
+ (primaryConnection.status === "active" && primaryConnection.enabled ? 1 : 0),
+ ),
+ });
+ }}
+ >
+
+
+ )}
+
);
@@ -370,6 +440,40 @@ export function Connections() {
)}
+
+ {
+ if (!open && !deleteConnection.isPending) setConnectionToDelete(null);
+ }}
+ >
+
+
+
+ Delete {connectionToDelete?.appName ?? "this"} connection?
+
+
+ {connectionToDelete && connectionToDelete.remainingConnectionCount > 0
+ ? `This connection will be removed. Agents can still use ${connectionToDelete.appName} through ${connectionToDelete.remainingConnectionCount} other active ${connectionToDelete.remainingConnectionCount === 1 ? "connection" : "connections"}.`
+ : "Agents will lose access immediately. You can connect it again later."}
+
+
+
+ Cancel
+ {
+ event.preventDefault();
+ if (connectionToDelete) deleteConnection.mutate(connectionToDelete);
+ }}
+ >
+ {deleteConnection.isPending && }
+ {deleteConnection.isPending ? "Deleting..." : "Delete connection"}
+
+
+
+
);
}
diff --git a/ui/src/pages/apps/gateways/AppsSubNav.tsx b/ui/src/pages/apps/gateways/AppsSubNav.tsx
index e6f734e9ef1..4977902141e 100644
--- a/ui/src/pages/apps/gateways/AppsSubNav.tsx
+++ b/ui/src/pages/apps/gateways/AppsSubNav.tsx
@@ -4,7 +4,7 @@ import { cn } from "@/lib/utils";
type SubNavKey = "connected" | "gateways" | "activity";
const ITEMS: { key: SubNavKey; label: string; href: string }[] = [
- { key: "connected", label: "Connected", href: "/apps" },
+ { key: "connected", label: "Connected", href: "/apps/connections" },
{ key: "gateways", label: "Gateways", href: "/apps/gateways" },
{ key: "activity", label: "Activity", href: "/activity" },
];
diff --git a/ui/src/pages/tools/PasteConfigTab.test.tsx b/ui/src/pages/tools/PasteConfigTab.test.tsx
index a91c7320bd7..7607380dd27 100644
--- a/ui/src/pages/tools/PasteConfigTab.test.tsx
+++ b/ui/src/pages/tools/PasteConfigTab.test.tsx
@@ -161,7 +161,7 @@ describe("PasteConfigTab — discoverability copy (PAP-11091)", () => {
a.textContent?.includes("Browse planned app connections"),
);
expect(link).toBeTruthy();
- expect(link?.getAttribute("href")).toBe("/apps/browse");
+ expect(link?.getAttribute("href")).toBe("/apps");
});
});
diff --git a/ui/src/pages/tools/PasteConfigTab.tsx b/ui/src/pages/tools/PasteConfigTab.tsx
index 2b4e54b7094..988e5b88546 100644
--- a/ui/src/pages/tools/PasteConfigTab.tsx
+++ b/ui/src/pages/tools/PasteConfigTab.tsx
@@ -164,7 +164,7 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
Just a URL?{" "}
-
+
Browse planned app connections
{" "}
instead.
diff --git a/ui/src/pages/tools/ToolsAccess.test.tsx b/ui/src/pages/tools/ToolsAccess.test.tsx
index c333ae68ebc..996c6a21f32 100644
--- a/ui/src/pages/tools/ToolsAccess.test.tsx
+++ b/ui/src/pages/tools/ToolsAccess.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { act } from "react";
+import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToolsAccess } from "./ToolsAccess";
@@ -52,6 +52,14 @@ vi.mock("./RunYourOwnTab", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+async function act(callback: () => void | Promise) {
+ let result: void | Promise = undefined;
+ flushSync(() => {
+ result = callback();
+ });
+ await result;
+}
+
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
@@ -88,7 +96,7 @@ describe("ToolsAccess", () => {
mockParams.tab = tab;
await render();
- expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps", replace: true }));
+ expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/connections", replace: true }));
},
);
diff --git a/ui/src/pages/tools/ToolsAccess.tsx b/ui/src/pages/tools/ToolsAccess.tsx
index efd002786aa..1ffeff80492 100644
--- a/ui/src/pages/tools/ToolsAccess.tsx
+++ b/ui/src/pages/tools/ToolsAccess.tsx
@@ -75,7 +75,7 @@ export function ToolsAccess() {
params.tab === "overview" ||
params.tab === "examples"
) {
- return ;
+ return ;
}
if (advanced) {
diff --git a/ui/src/pages/tools/profiles/WizardToolsStep.tsx b/ui/src/pages/tools/profiles/WizardToolsStep.tsx
index 83cfa37cd61..07a4a5d91f8 100644
--- a/ui/src/pages/tools/profiles/WizardToolsStep.tsx
+++ b/ui/src/pages/tools/profiles/WizardToolsStep.tsx
@@ -89,7 +89,7 @@ export function WizardToolsStep(props: WizardToolsStepProps) {
- Browse app connections
+ Browse app connections
);
From 3435920ae18bc328b7ee6275395d827adbe36bf1 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 7 Aug 2026 15:00:41 -0500
Subject: [PATCH 005/201] docs: minimize plan task graphs (#11057)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip skills guide agents through repeatable work.
> - The plan-to-task skill guides agents when they create issue graphs
from plans.
> - The prior guidance could encourage an issue for every concrete
deliverable.
> - That guidance can create unnecessary subtasks for work that one
owner can complete end to end.
> - This pull request defines the boundaries that justify a separate
task.
> - It also adds a merge-back pass that removes task splits without a
qualifying boundary.
> - The benefit is a smaller issue graph with clear ownership,
dependencies, and review gates.
## Linked Issues or Issue Description
**Issue type**
Unclear or confusing documentation.
**Where is the issue?**
`skills/paperclip-converting-plans-to-tasks/SKILL.md`
**What's wrong?**
The skill says that each concrete deliverable must become an issue. This
can make agents split one end-to-end job into tasks for each step, file,
component, or phase. The result is more coordination work without a real
execution boundary.
**Suggested fix**
Tell agents to start with one end-to-end task. Permit separate tasks
only for ownership, parallel work, dependencies, independent review or
approval, or substantial follow-up work. Require a merge-back pass
before agents create the issue graph.
## What Changed
- Added a rule to use the fewest tasks that can complete and verify the
work.
- Defined the boundaries that qualify work for a separate issue.
- Added a merge-back pass for proposed subtasks without a qualifying
reason.
- Updated dependency, parallel work, verification, and checklist
guidance to enforce the smaller graph.
## Verification
- Ran `pnpm exec vitest run packages/shared/src/frontmatter.test.ts`.
- Result: 1 test file passed and 21 tests passed.
- Ran `git diff --check public-gh/master...HEAD`.
- Confirmed that the PR changes one skill file and has no lockfile,
workflow, image, or migration changes.
## Risks
- Low risk. This change updates skill guidance only.
- Agents might combine work too aggressively. The qualifying boundaries
preserve separate ownership, parallel execution, dependencies, review,
approval, and substantial follow-up work.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI GPT-5 through Codex. The exact deployment ID and context window
are not exposed. The agent used reasoning, repository tools, command
execution, and GitHub tools.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
.../SKILL.md | 26 ++++++++++++++-----
1 file changed, 19 insertions(+), 7 deletions(-)
diff --git a/skills/paperclip-converting-plans-to-tasks/SKILL.md b/skills/paperclip-converting-plans-to-tasks/SKILL.md
index 112f3b15115..70eeba0f7ef 100644
--- a/skills/paperclip-converting-plans-to-tasks/SKILL.md
+++ b/skills/paperclip-converting-plans-to-tasks/SKILL.md
@@ -15,30 +15,42 @@ For the **mechanics** of recording a plan (issue document with key `plan`, comme
## When you're asked to plan
- **Plan deeply.** Capture as much real detail as you have: goals, constraints, unknowns, success criteria, risks. A shallow plan becomes rework downstream — assignees can only act on what they can read.
+- **Minimize the issue graph.** Use as few tasks as possible while still completing and verifying the job. Prefer one end-to-end task with one owner over separate tasks for each step, file, component, or phase. Keep those structural details as checklists or acceptance criteria inside the owning task unless a real execution boundary requires another issue.
+- **Split only for a qualifying boundary.** Create a separate subtask only when at least one of these applies:
+ - A different specialist, owner, permission boundary, or external actor must own the work.
+ - A self-contained deliverable can usefully run in parallel with other work.
+ - A hard dependency or handoff needs its own `blockedByIssueIds` lifecycle.
+ - A review, QA pass, or governed approval gate has an independent owner.
+ - Substantial follow-up work needs independent tracking or retry because it cannot safely be completed and verified in the parent.
- **Know your team.** Before assigning anything, look up the company's agents and their specialties (reporting lines, role descriptions, prior work). Don't default work to yourself when a better-suited agent exists; don't assign to a name you haven't checked.
- **Assign for specialty.** Hand each piece of work to the agent most relevant to it. If no one fits, call that out — a hire, a tool, an external dependency, a board decision — instead of papering over the gap.
- **Take responsibility.** Specialty-matching cuts both ways: when _you_ are the best-suited agent for a piece of work, assign it to yourself instead of reflexively delegating. Don't hand off to avoid load.
-- **Use the dependency tree.** Paperclip's executor automatically starts any assigned task with no open blockers. Parent/child issue nesting is structure, not execution blocking. Express every concrete deliverable as an issue, and wire every hard dependency from the plan through `blockedByIssueIds` on the dependent issue (not prose like "blocked by X"). When a blocker reaches `done`, dependents auto-wake.
-- **Order, then parallelize.** Sequence work by real dependencies, not by personal preference. Independent branches of the graph should start in parallel. Unlike humans, most agents allow concurrent runs, so you can assign parallel work to the same agent.
+- **Use the dependency tree.** Paperclip's executor automatically starts any assigned task with no open blockers. Parent/child issue nesting is structure, not execution blocking. Express each qualifying ownership or lifecycle boundary as an issue; keep other concrete deliverables within the responsible issue's description, checklist, or acceptance criteria. Wire every hard dependency between issues through `blockedByIssueIds` on the dependent issue (not prose like "blocked by X"). When a blocker reaches `done`, dependents auto-wake.
+- **Order, then parallelize.** Sequence work by real dependencies, not by personal preference. Create parallel branches only for qualifying, self-contained work, then start those independent branches in parallel. Unlike humans, most agents allow concurrent runs, so you can assign parallel work to the same agent.
- **Write review tasks for the reviewer's boundary.** A review/QA task must tell the delegate to post findings on **their own review issue** and mark it `done` — the verdict is the deliverable, and adverse findings are still `done`, not `blocked`. Never instruct a delegate to comment on the parent issue (low-trust reviewers are guaranteed a 403 there), and make the description self-contained since the reviewer may not be able to read your issue. Wire the dependent issue's `blockedByIssueIds` to the review issue so the verdict wakes the right owner.
- **Enough is enough.** Plans exist to unblock execution, not replace it. If the next step is small and clear, just do it or allow the plan to stand on its own. Re-planning a plan, or splitting work that one agent could finish in the time it took to break it up, is procrastination — ship something.
## When converting an accepted plan into tasks
-Before or while creating tasks, write a compact task matrix with each planned task, owner, initial status, and blockers. Any task that can start immediately should say why it has no blockers; otherwise set it to `blocked` and include the prerequisite issue IDs in `blockedByIssueIds`. Do not rely on `parentId`, child ordering, phase labels, or prose to block execution.
+Start from one end-to-end task and add issues only for the qualifying boundaries above. Before creating tasks, write a compact task matrix with each proposed task, owner, initial status, blockers, and the specific qualifying reason it must be separate. Any task that can start immediately should say why it has no blockers; otherwise set it to `blocked` and include the prerequisite issue IDs in `blockedByIssueIds`. Do not rely on `parentId`, child ordering, phase labels, or prose to block execution.
-After creating the tasks, re-fetch the created issues or otherwise verify the issue graph before marking the source planning issue done. Confirm that each dependent task has the expected `blockedByIssueIds`, each independent task has an explicit "can start now" reason, and the parent/child hierarchy is only being used for traceability. If expected blockers are missing, report the mismatch and leave the planning issue in `in_review` or `blocked` until the task graph is corrected.
+Run a merge-back pass before publishing or creating the graph. Require every proposed subtask to name at least one qualifying reason from this skill. If it cannot, merge it into its parent or an adjacent task and preserve the work as an internal step, checklist item, or acceptance criterion. Repeat until every remaining issue has a real ownership, scheduling, lifecycle, or governance reason to exist.
+
+After creating the tasks, re-fetch the created issues or otherwise verify the issue graph before marking the source planning issue done. Confirm that every separate issue still has its qualifying reason, each dependent task has the expected `blockedByIssueIds`, each independent task has an explicit "can start now" reason, review tasks respect the reviewer's write boundary, and the parent/child hierarchy is only being used for traceability. If the graph contains an unjustified split or expected blockers are missing, correct it or report the mismatch and leave the planning issue in `in_review` or `blocked` until the graph is fixed.
## Quick checklist before you publish a plan
- [ ] Enough detail that assignees can act without re-asking.
-- [ ] Every concrete deliverable is an issue (or named as a known follow-up).
+- [ ] The plan uses the fewest tasks that can complete and verify the job, preferring one end-to-end owner over step/file/component/phase splits.
+- [ ] Every concrete deliverable is accounted for inside an issue or, only when a qualifying boundary applies, as its own issue.
+- [ ] Every proposed subtask names a qualifying reason; otherwise it was merged into its parent or an adjacent task.
- [ ] Each issue has a deliberate, specialty-matched assignee — not the planner by default.
- [ ] Each issue's real blockers are declared via `blockedByIssueIds`.
-- [ ] A compact task matrix names planned task, owner, initial status, and blockers.
+- [ ] Independently owned review, QA, and governed approval tasks respect the reviewer's boundary.
+- [ ] A compact task matrix names planned task, owner, initial status, blockers, and qualifying reason.
- [ ] Tasks without blockers have an explicit reason they can start immediately.
- [ ] Created issues were re-fetched or otherwise verified before closing the source planning issue.
-- [ ] Independent branches can start in parallel.
+- [ ] Qualifying independent branches can start in parallel.
- [ ] Gaps (missing skills, hires, decisions, external inputs) are surfaced, not hidden.
## What this skill is not
From a71b9cf6284020e3cabbc0f7916ec8dcb7c4cc7b Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 7 Aug 2026 15:40:11 -0500
Subject: [PATCH 006/201] feat(skills): add MCP integration preparation skill
(#11063)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The Skills Store lets a company find and install reusable agent
procedures
> - The MCP integration preparation procedure existed outside the app
catalog
> - Paperclip users could not find or install that procedure from the
product
> - This pull request adds the procedure as an optional software
development skill
> - The skill keeps research, human approval, and connector delivery as
separate gates
> - The benefit is a repeatable and governed path from vendor research
to one connector pull request
## Linked Issues or Issue Description
**What existing behavior does this improve?**
The app-shipped Skills Store can install optional skills, but it does
not include the MCP integration preparation workflow from
`paperclip-content`.
**Subsystem affected**
`packages/skills-catalog`.
**Current behavior**
An agent must know where the external workflow lives. The agent cannot
find or install it from the Paperclip skills catalog.
**Proposed behavior**
The optional catalog includes `prepare-mcp-integration`. The installed
skill directs agents through cited research, a research-only content
pull request, an exact-revision human gate, and one Paperclip connector
pull request per approved connection.
**Reason and benefit**
This change makes the existing integration and connector playbooks
available as one installable Paperclip workflow. It also prevents agents
from starting connector code before the research gate is approved.
**Breaking changes**
None. The skill is optional and markdown-only.
Related source work: paperclipai/paperclip-content#13.
## What Changed
- Add the optional `prepare-mcp-integration` catalog skill under
software development
- Add Paperclip catalog metadata for roles, requirements, tags, and
trust classification
- Add a worked Notion MCP research-gate example
- Regenerate the checked-in catalog manifest
- Add the new key to the shipped optional skill test
## Verification
- `pnpm --filter @paperclipai/skills-catalog build:manifest`
- `pnpm --filter @paperclipai/skills-catalog validate`
- `pnpm --filter @paperclipai/skills-catalog test`
- Confirm the generated catalog contains
`paperclipai/optional/software-development/prepare-mcp-integration`
- Confirm the trust level is `markdown_only` and compatibility is
`compatible`
## Risks
- Low risk. This change adds one optional markdown-only catalog entry.
- The workflow can become stale if the two upstream playbooks change.
The skill requires agents to read the current playbooks before each
phase and to update upstream rules when reusable specifications change.
## Model Used
OpenAI Codex with GPT-5.4, reasoning mode, shell tool use, and code
editing.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
.../prepare-mcp-integration/SKILL.md | 210 ++++++++++++++++++
.../examples/notion-mcp-research-gate.md | 43 ++++
.../skills-catalog/generated/catalog.json | 55 ++++-
.../src/shipped-catalog.test.ts | 1 +
4 files changed, 305 insertions(+), 4 deletions(-)
create mode 100644 packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/SKILL.md
create mode 100644 packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/examples/notion-mcp-research-gate.md
diff --git a/packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/SKILL.md b/packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/SKILL.md
new file mode 100644
index 00000000000..a7ba10b3e02
--- /dev/null
+++ b/packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/SKILL.md
@@ -0,0 +1,210 @@
+---
+name: prepare-mcp-integration
+description: >
+ Prepare MCP/vendor integrations through cited research, a content PR,
+ exact-revision human approval, and one governed Paperclip connector PR per
+ approved connection. Use for new integration research and delivery; not for
+ ad hoc connector coding that bypasses the playbooks.
+key: paperclipai/optional/software-development/prepare-mcp-integration
+recommendedForRoles:
+ - engineer
+ - product-manager
+ - researcher
+tags:
+ - mcp
+ - integrations
+ - connectors
+ - research
+ - github
+ - human-approval
+requires:
+ - git
+ - gh
+ - curl
+---
+
+# Prepare MCP Integration
+
+Take an input link or vendor brief through two separate phases: a reviewable
+research PR in `paperclip-content`, then connector implementation in Paperclip
+App only after a human accepts the exact research revision and connection set.
+
+## Preserve These Boundaries
+
+- Treat `paperclip-content/integrations/README.md` as the research contract and
+ `paperclip-content/integrations/skills/integration-harness/SKILL.md` as its
+ entrypoint. Reference and run them; do not copy their schemas, templates,
+ state machines, reconciliation rules, or internal gates into this skill.
+- Treat `paperclip/doc/connections/CONNECTOR-PLAYBOOK.md` on the implementation
+ target branch as the connector contract. Follow it end to end; do not
+ substitute remembered behavior or the examples in this skill.
+- Finish Phase A with a research-only PR. Do not create an App implementation
+ branch, task, or code change before the research gate is accepted.
+- Bind acceptance to one research PR head SHA and an explicit connection set.
+ Acceptance does not cover later commits or additional connections.
+- Create one Paperclip App PR per approved connection. Shared prerequisite
+ infrastructure or broad playbook corrections may use separate prerequisite
+ PRs; never combine distinct connections into one connector PR.
+- Keep vendor credentials in approved secret storage. Never put secrets in
+ briefs, catalog files, issue text, plans, fixtures, screenshots, logs, branch
+ names, commits, or PRs.
+
+## Preflight
+
+1. Load the current Paperclip skill for checkout, comments, interactions,
+ durable state, and final disposition. Load the standard PR-preparation skill
+ (`prepare-paperclip-pr` for Paperclip agents) before opening any PR.
+2. Resolve the input URL(s), vendor/platform, intended MCP endpoint or API,
+ target repositories and branches, and the Paperclip issue that owns the
+ work. Ask only when these cannot be determined safely from the brief.
+3. Fetch both repositories and record the target commit hashes. Read the
+ canonical files from those target commits, not from a possibly stale working
+ tree. Refresh and reread them again immediately before Phase B.
+4. Inventory existing integration catalog entities, open PRs, branches, and
+ current AppDefinitions/connectors before creating anything. Match by
+ meaning, not title or slug.
+5. Determine whether the input describes one connection or several. A
+ connection has one coherent credential owner, endpoint/transport, resource
+ boundary, and independently reviewable action catalog. Record the proposed
+ split early and refine it as evidence arrives.
+6. Prefer official vendor documentation, protocol/RFC sources, safe live
+ probes, and current Paperclip code. Use third-party sources only to find or
+ qualify primary evidence. Record every factual claim with URL and access
+ date; mark unresolved facts explicitly instead of guessing.
+
+Do not mutate a vendor account, register a client, grant consent, or invoke a
+write tool merely to research it. A safe unauthenticated metadata probe is
+allowed when it does not change vendor state. Route credential- or
+browser-dependent validation through an explicit QA task when it becomes
+necessary.
+
+## Keep The Run Resumable
+
+Maintain one concise checkpoint in the Paperclip issue or an issue document:
+
+- current phase and owning next action;
+- input links and target repository commits;
+- research PR URL and exact head SHA;
+- proposed and accepted connection sets;
+- research-gate interaction and accepted target revision;
+- one branch, PR URL/head, and verification summary per connection;
+- prerequisite/playbook PRs and remaining blockers.
+
+On every restart, reconcile this state with files, branches, PRs, reviews, and
+interactions before creating anything. Reuse semantic matches. Never duplicate
+catalog entities, regress terminal pipeline phases, reuse stale acceptance, or
+open a second PR for the same connection accidentally.
+
+## Phase A: Research In paperclip-content
+
+1. Create an isolated worktree and branch from the refreshed content target.
+ Preserve unrelated local changes.
+2. Intake the supplied links or brief through the integration harness. Let the
+ harness load its sibling skills for discovery, feature research, proposal
+ reconciliation, user stories, UI planning, implementation planning,
+ examples, and docs briefing. Obey every internal human gate in
+ `integrations/README.md`; the final research gate below does not replace
+ them.
+3. Build the complete reviewable OKF/planning package required by the current
+ pipeline. For MCP work, make the evidence sufficient to decide:
+ - official endpoint and transport;
+ - auth mode, credential ownership, scopes, discovery and DCR behavior;
+ - endpoint-precedence and redirect-origin constraints;
+ - token lifetime, rotation, refresh, revocation, and re-auth behavior;
+ - tool inventory, action risk, resource filters, account/tier/pricing
+ constraints, administrator setup, and validation needs;
+ - exact service involvement and system boundaries in Paperclip.
+4. Ground every Paperclip-surface claim in the maintained surface map and
+ current App code. Reconcile before creating, update required indexes/logs,
+ and preserve lineage, timestamps, immutable slugs, and absorbing phases as
+ required by the research contract.
+5. Open one research-only PR to `paperclip-content`. Include the full planning
+ package and any tightly coupled content-playbook correction, but no
+ Paperclip App implementation.
+6. Run focused validation and the required PR workflow. Do not present the gate
+ until checks are green, Greptile is 5/5, all actionable review comments are
+ resolved, and the recorded PR head still matches the reviewed head.
+
+## Gate Research Before Building
+
+1. Create or update a dedicated issue document that names:
+ - the research PR URL and exact head SHA;
+ - the content and App source commits used for research;
+ - the proposed connection set and why each item is independent;
+ - known limitations, prerequisites, and deferred questions.
+2. Create a Paperclip `request_confirmation` interaction targeted at that issue
+ document's latest revision. Use a revision-specific idempotency key and a
+ `wake_assignee` continuation policy so either acceptance or rejection wakes
+ the assignee. Ask the reviewer to include revision notes when rejecting.
+3. Put the issue in `in_review` and stop. Do not prepare App worktrees or code
+ while the interaction is pending.
+4. On rejection, use the interaction response and any revision notes to revise
+ Phase A only and present a new revision. If the research PR head, gate
+ document, or connection set changes, withdraw/supersede the old confirmation
+ and request a fresh one.
+5. On acceptance, verify that the response still targets the latest gate
+ revision and recorded PR head. Implement only the accepted connections.
+
+## Phase B: Implement In Paperclip App
+
+Refresh the App target branch, reread the current Connector Playbook, and record
+its commit before writing code. For each accepted connection:
+
+1. Create one isolated worktree, branch, and PR. Reconcile against current
+ AppDefinitions and connector code first.
+2. Follow the Connector Playbook's current decisions for catalog entry versus
+ plugin, reuse path, AppDefinition, transport, credential refs, resource
+ filters, action catalog, governance, wizard behavior, health/catalog,
+ availability, revocation, audit, and validation.
+3. Derive OAuth behavior from evidence. In particular, do not ship a complete
+ authorization/token endpoint pair for a discovery-capable MCP vendor unless
+ it is intentionally authoritative: current broker precedence can make that
+ pair bypass stored endpoints, challenge hints, and RFC discovery. Probe DCR
+ and redirect constraints safely, reuse registered clients, and cover token
+ rotation/terminal refresh failures when the vendor requires them.
+4. Add the connection documentation mandated by the current playbook,
+ including the service-involvement statement, a sequence diagram with exact
+ auth/discovery/registration/callback endpoints, and step-by-step
+ administrator setup.
+5. Add focused automated tests and the production-like validation hook for
+ connect, catalog discovery, an allowed read, an ask-first write, a
+ denied/quarantined action, revoke, and audit. Include negative company,
+ actor, resource, and changed-schema cases where applicable.
+6. Use a first-class QA child issue only when real credentials, vendor consent,
+ or browser evidence cannot be completed safely by the implementing agent.
+ Link it as a blocker and give QA exact, secret-safe steps and expected
+ evidence.
+7. Run the standard PR-preparation workflow for this connector PR. Do not hand
+ it back for merge until focused verification passes, all required checks are
+ green, Greptile is 5/5, and every actionable comment is resolved.
+
+Complete and report each connection independently. Failure or review delay on
+one connection must not cause another connection to be bundled into its PR.
+
+## Feed New Rules Upstream
+
+At both phases, compare new evidence with the two canonical playbooks.
+
+- Record vendor-specific facts in that vendor's catalog artifacts, manifest,
+ docs, and tests.
+- When evidence changes a reusable schema, gate, template, auth rule, risk
+ policy, documentation standard, or validation rule, update the owning
+ upstream playbook/skill/template and add regression coverage where possible.
+- Include a narrow, tightly coupled correction in the relevant research or
+ connector PR. Use a separate prerequisite PR when the correction affects
+ several connectors, changes shared infrastructure, or would obscure the
+ one-connection review.
+- Rebase/reconcile dependent work and rerun affected planning and verification
+ after the correction. If the accepted research PR or connection set changes,
+ repeat the research gate.
+- Never knowingly leave implementation, tests, and the owning playbook
+ inconsistent.
+
+## Finish
+
+Before marking the issue done, report the exact research PR and head, accepted
+gate revision, every connector/prerequisite PR and head, focused verification,
+CI/review state, and any upstream playbook changes. Leave the issue `in_review`
+only for a real pending interaction/reviewer/monitor path, `blocked` only for a
+named owner and concrete unblock action, and `done` only when every approved
+connection has a merge-ready PR and no required follow-up remains on the issue.
diff --git a/packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/examples/notion-mcp-research-gate.md b/packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/examples/notion-mcp-research-gate.md
new file mode 100644
index 00000000000..8abdfb1f001
--- /dev/null
+++ b/packages/skills-catalog/catalog/optional/software-development/prepare-mcp-integration/examples/notion-mcp-research-gate.md
@@ -0,0 +1,43 @@
+# Example: Research and Gate a Notion MCP Connection
+
+## Input
+
+> Research Notion's hosted MCP server and prepare it for Paperclip. Start from
+> the vendor documentation URL. Do not build the connector until I approve the
+> research.
+
+## Application
+
+1. Read the current `paperclip-content/integrations/README.md` and integration
+ harness from the content target commit.
+2. Research the official Notion MCP, OAuth, scopes, tools, limits, and admin
+ setup. Record URLs and access dates, and mark unknowns rather than guessing.
+3. Reconcile existing Notion integration artifacts and open PRs before adding
+ or changing catalog content.
+4. Open a research-only `paperclip-content` PR containing the planning package
+ required by the integrations playbook.
+5. Record the research PR head SHA and proposed connection set in an issue
+ document, then request confirmation against that exact revision.
+6. Stop with the issue in review. No Paperclip App branch exists yet.
+
+## Gate Output
+
+```text
+Research PR: https://github.com/paperclipai/paperclip-content/pull/123
+Research head: 0123456789abcdef0123456789abcdef01234567
+Content source: 89abcdef0123456789abcdef0123456789abcdef
+App source: fedcba9876543210fedcba9876543210fedcba98
+Proposed connection set:
+- notion-mcp: one OAuth credential owner, hosted MCP endpoint, and independently
+ reviewable Notion action catalog
+Known limitations:
+- Production OAuth consent still requires credentialed QA after approval.
+Next action:
+- Human confirms or rejects this exact research revision.
+```
+
+After acceptance, create one isolated Paperclip App worktree and PR for
+`notion-mcp`, reread the current Connector Playbook, and follow its current
+implementation and validation requirements. If research reveals a reusable
+OAuth or documentation rule, update the owning upstream playbook in the
+appropriate PR before declaring the connector merge-ready.
diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json
index 9456225a9e0..ad63e175690 100644
--- a/packages/skills-catalog/generated/catalog.json
+++ b/packages/skills-catalog/generated/catalog.json
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"packageName": "@paperclipai/skills-catalog",
"packageVersion": "0.3.1",
- "generatedAt": "2026-07-28T22:35:25.645Z",
+ "generatedAt": "2026-08-07T20:30:29.973Z",
"skills": [
{
"id": "paperclipai:bundled:docs:doc-maintenance",
@@ -102,11 +102,11 @@
{
"path": "SKILL.md",
"kind": "skill",
- "sizeBytes": 11837,
- "sha256": "86d6e382c32c626213b46c010cc7fc1ec460821a8c777cfa5b8e86fa9d6a86b2"
+ "sizeBytes": 12028,
+ "sha256": "083d5fa8dc43c3ce7747e89c1afb6f6f42d0603a31808bffc5f8e56735f2d865"
}
],
- "contentHash": "sha256:1c7a82cd9638a1d845b238032da3ff4ad80c5b6a87dca46082f501fa4583db55"
+ "contentHash": "sha256:f2592d81f616628a3eba92636616424f2eed82ec22bb504240a58f6be115e407"
},
{
"id": "paperclipai:bundled:paperclip-operations:status-card-query",
@@ -1113,6 +1113,53 @@
"path": "skills/last30days",
"url": "https://github.com/mvanhorn/last30days-skill/tree/v3.3.0/skills/last30days"
}
+ },
+ {
+ "id": "paperclipai:optional:software-development:prepare-mcp-integration",
+ "key": "paperclipai/optional/software-development/prepare-mcp-integration",
+ "kind": "optional",
+ "category": "software-development",
+ "slug": "prepare-mcp-integration",
+ "name": "prepare-mcp-integration",
+ "description": "Prepare MCP/vendor integrations through cited research, a content PR, exact-revision human approval, and one governed Paperclip connector PR per approved connection. Use for new integration research and delivery; not for ad hoc connector coding that bypasses the playbooks.",
+ "path": "catalog/optional/software-development/prepare-mcp-integration",
+ "entrypoint": "SKILL.md",
+ "trustLevel": "markdown_only",
+ "compatibility": "compatible",
+ "defaultInstall": false,
+ "recommendedForRoles": [
+ "engineer",
+ "product-manager",
+ "researcher"
+ ],
+ "requires": [
+ "git",
+ "gh",
+ "curl"
+ ],
+ "tags": [
+ "mcp",
+ "integrations",
+ "connectors",
+ "research",
+ "github",
+ "human-approval"
+ ],
+ "files": [
+ {
+ "path": "SKILL.md",
+ "kind": "skill",
+ "sizeBytes": 11183,
+ "sha256": "452386be3aa5e2988dc9d72f288f469975da766626a25c48017877963c533781"
+ },
+ {
+ "path": "examples/notion-mcp-research-gate.md",
+ "kind": "markdown",
+ "sizeBytes": 1900,
+ "sha256": "944b13ac67c06c65643eb236e856f1479106749861535648d29334f84cf1b50b"
+ }
+ ],
+ "contentHash": "sha256:4dbb76624e0fd5502fa10a9168f77214c5020899838af887e77a1617a0a00c31"
}
]
}
diff --git a/packages/skills-catalog/src/shipped-catalog.test.ts b/packages/skills-catalog/src/shipped-catalog.test.ts
index 2c9211f6951..5069d5600b8 100644
--- a/packages/skills-catalog/src/shipped-catalog.test.ts
+++ b/packages/skills-catalog/src/shipped-catalog.test.ts
@@ -24,6 +24,7 @@ const EXPECTED_OPTIONAL_KEYS = [
"paperclipai/optional/finance/ramp",
"paperclipai/optional/product/design-critique",
"paperclipai/optional/research/last30days",
+ "paperclipai/optional/software-development/prepare-mcp-integration",
];
const MAX_FRONTMATTER_DESCRIPTION_LENGTH = 300;
From 0a511ed1b031f0a3d17c8e3df062530d990b3a1c Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Fri, 7 Aug 2026 16:28:04 -0500
Subject: [PATCH 007/201] feat(apps): support multiple provider connections
(#11060)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The Apps subsystem connects company tools through governed provider
connections.
> - A company can need more than one account for the same provider.
> - The current database constraint and Apps flow assume one named
connection per company.
> - New quarantined actions also need an explicit review decision before
activation.
> - This pull request supports multiple provider connections and
complete action review decisions.
> - The benefit is safer access control and a clear multi-account Apps
workflow.
## Linked Issues or Issue Description
Refs: #11040
**Subsystem affected**
Cross-cutting. This change affects the Apps UI, the tool access API, the
shared request contract, and the database schema.
**Problem or motivation**
The connection name constraint prevents a company from keeping more than
one connection for a provider. The Apps UI also reuses an existing OAuth
connection when a user asks to connect another account. Action review
can enable selected entries without recording a decision for every
quarantined action.
**Proposed solution**
Remove the company and connection name uniqueness constraint. Let users
open, count, edit, and create multiple provider connections. Require the
finish request to cover every quarantined action exactly once before the
server activates reviewed entries.
**Alternatives considered**
The UI could generate unique internal names and keep the database
constraint. This would preserve a one-connection assumption in the data
model and would make display names part of identity. The server could
also infer review decisions from enabled actions. This would not
distinguish a reviewed disabled action from an action that the user did
not review.
**Roadmap alignment**
This change extends the completed MCP Tool Gateway and Apps milestone.
It also supports the Connected Apps roadmap item. It follows the
navigation and connection management work in #11040.
## What Changed
- Remove the company-scoped connection name uniqueness index with an
ordered and idempotent migration.
- Add a reviewed action list to the finish-app contract and reject
incomplete or duplicate review decisions.
- Activate reviewed entries and keep unreviewed quarantined entries
blocked.
- Enable a completed connection and preserve the company and connection
scope in all updates.
- Show provider connection counts and open the provider setup page from
Browse.
- Let users edit existing connections or connect another account without
reusing an active OAuth connection.
- Update focused server and UI coverage for multiple connections and
action review.
## Verification
- Ran the focused Apps UI suite. All 116 tests passed in 11 files.
- Ran the focused server and CLI suite. All 276 tests passed in 3 files.
- Ran `pnpm --filter @paperclipai/db check:migrations`. The migration
safety check passed.
- Ran `pnpm -r typecheck`. All projects passed.
- Ran `pnpm build`. All projects built successfully.
- Ran `pnpm test:run`. It passed 3,735 tests and skipped 4 tests. One
worktree-safety assertion failed because the execution workspace reloads
its worktree marker. The same test passed with an isolated non-worktree
marker.
- Ran `pnpm check:token-gates`. It reports 12 existing violations in the
unchanged `PaperclipOrbit3D.tsx` file from the target branch.
- Started the six affected Playwright specifications. Chromium could not
start because the host does not provide `libatk-1.0.so.0`. The GitHub
e2e jobs will verify these specifications.
- GitHub Actions passed every final-head CI gate, including all three
e2e shards and the aggregate `e2e` and `verify` jobs.
- Greptile reviewed final commit `9af9200426` at 5/5 with zero review
threads.
## Risks
- Removing the name uniqueness index permits duplicate display names.
Stable connection IDs and UIDs remain unique within a company.
- The finish-app endpoint accepts the new review field as optional for
backward compatibility. When clients send it, the server requires a
complete decision for all quarantined actions.
- Multiple OAuth connections depend on the explicit new-connection route
flag. Focused tests cover active and draft connection reuse.
- The migration is ordered after migration 0210. Its `DROP INDEX IF
EXISTS` statement is safe to repeat.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
OpenAI Codex with the `gpt-5.6-sol` model assisted this change. The
agent used repository tools, code execution, test execution, and agentic
reasoning. The Codex runtime manages the context window.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
.../db/src/migrations/0211_bright_morg.sql | 1 +
.../db/src/migrations/meta/0211_snapshot.json | 38676 ++++++++++++++++
packages/db/src/migrations/meta/_journal.json | 7 +
packages/db/src/schema/tool_access.ts | 1 -
packages/shared/src/validators/tool-access.ts | 1 +
.../src/__tests__/tool-access-service.test.ts | 84 +-
server/src/routes/tool-access.ts | 1 +
server/src/services/tool-access.ts | 50 +-
tests/e2e/app-not-connected.spec.ts | 8 +-
tests/e2e/applications-crud.spec.ts | 12 +-
ui/src/api/tools.ts | 1 +
.../components/AppConnectionSidebar.test.tsx | 2 +-
ui/src/components/AppConnectionSidebar.tsx | 3 +-
ui/src/components/AppsSidebar.test.tsx | 4 +-
ui/src/components/AppsSidebar.tsx | 11 +-
ui/src/components/Layout.test.tsx | 2 +-
ui/src/components/Layout.tsx | 11 +-
ui/src/pages/apps/AppDetail.test.tsx | 66 +-
ui/src/pages/apps/AppDetail.tsx | 33 +-
ui/src/pages/apps/AppNotConnected.test.tsx | 51 +
ui/src/pages/apps/AppNotConnected.tsx | 119 +-
ui/src/pages/apps/AppsConnect.test.tsx | 52 +
ui/src/pages/apps/AppsConnect.tsx | 30 +-
ui/src/pages/apps/Browse.test.tsx | 38 +-
ui/src/pages/apps/Browse.tsx | 83 +-
ui/src/pages/apps/Connections.test.tsx | 10 +-
ui/src/pages/apps/Connections.tsx | 10 +-
ui/src/pages/apps/app-definition-display.ts | 14 +-
.../apps/app-detail/PermissionsPanel.tsx | 17 +-
ui/src/pages/apps/app-detail/ReviewPanel.tsx | 13 +-
ui/src/pages/apps/app-detail/SetupPanel.tsx | 94 +-
.../permitted-vs-installed.stories.tsx | 2 +-
32 files changed, 39376 insertions(+), 131 deletions(-)
create mode 100644 packages/db/src/migrations/0211_bright_morg.sql
create mode 100644 packages/db/src/migrations/meta/0211_snapshot.json
diff --git a/packages/db/src/migrations/0211_bright_morg.sql b/packages/db/src/migrations/0211_bright_morg.sql
new file mode 100644
index 00000000000..1f10b453407
--- /dev/null
+++ b/packages/db/src/migrations/0211_bright_morg.sql
@@ -0,0 +1 @@
+DROP INDEX IF EXISTS "tool_connections_company_name_uq";
diff --git a/packages/db/src/migrations/meta/0211_snapshot.json b/packages/db/src/migrations/meta/0211_snapshot.json
new file mode 100644
index 00000000000..99f4104355c
--- /dev/null
+++ b/packages/db/src/migrations/meta/0211_snapshot.json
@@ -0,0 +1,38676 @@
+{
+ "id": "c56d3441-2ed4-4249-a115-99d55d395899",
+ "prevId": "ecc034cd-9b49-418f-b40e-7372ef1e6eeb",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.activity_log": {
+ "name": "activity_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "activity_log_company_created_idx": {
+ "name": "activity_log_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_company_agent_created_idx": {
+ "name": "activity_log_company_agent_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_company_responsible_user_created_idx": {
+ "name": "activity_log_company_responsible_user_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "responsible_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_run_id_idx": {
+ "name": "activity_log_run_id_idx",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "activity_log_entity_type_id_idx": {
+ "name": "activity_log_entity_type_id_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "entity_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "activity_log_company_id_companies_id_fk": {
+ "name": "activity_log_company_id_companies_id_fk",
+ "tableFrom": "activity_log",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "activity_log_agent_id_agents_id_fk": {
+ "name": "activity_log_agent_id_agents_id_fk",
+ "tableFrom": "activity_log",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "activity_log_run_id_heartbeat_runs_id_fk": {
+ "name": "activity_log_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "activity_log",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_api_keys": {
+ "name": "agent_api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope_config": {
+ "name": "scope_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_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": {
+ "agent_api_keys_key_hash_idx": {
+ "name": "agent_api_keys_key_hash_idx",
+ "columns": [
+ {
+ "expression": "key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_api_keys_company_agent_idx": {
+ "name": "agent_api_keys_company_agent_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_api_keys_agent_id_agents_id_fk": {
+ "name": "agent_api_keys_agent_id_agents_id_fk",
+ "tableFrom": "agent_api_keys",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agent_api_keys_company_id_companies_id_fk": {
+ "name": "agent_api_keys_company_id_companies_id_fk",
+ "tableFrom": "agent_api_keys",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_config_revisions": {
+ "name": "agent_config_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'patch'"
+ },
+ "rolled_back_from_revision_id": {
+ "name": "rolled_back_from_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "changed_keys": {
+ "name": "changed_keys",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "before_config": {
+ "name": "before_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "after_config": {
+ "name": "after_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_config_revisions_company_agent_created_idx": {
+ "name": "agent_config_revisions_company_agent_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_config_revisions_agent_created_idx": {
+ "name": "agent_config_revisions_agent_created_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_config_revisions_company_id_companies_id_fk": {
+ "name": "agent_config_revisions_company_id_companies_id_fk",
+ "tableFrom": "agent_config_revisions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agent_config_revisions_agent_id_agents_id_fk": {
+ "name": "agent_config_revisions_agent_id_agents_id_fk",
+ "tableFrom": "agent_config_revisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_config_revisions_created_by_agent_id_agents_id_fk": {
+ "name": "agent_config_revisions_created_by_agent_id_agents_id_fk",
+ "tableFrom": "agent_config_revisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_memberships": {
+ "name": "agent_memberships",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'joined'"
+ },
+ "starred_at": {
+ "name": "starred_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_memberships_company_user_idx": {
+ "name": "agent_memberships_company_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_memberships_company_user_starred_idx": {
+ "name": "agent_memberships_company_user_starred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "starred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_memberships_agent_idx": {
+ "name": "agent_memberships_agent_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_memberships_company_user_agent_uq": {
+ "name": "agent_memberships_company_user_agent_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_memberships_company_id_companies_id_fk": {
+ "name": "agent_memberships_company_id_companies_id_fk",
+ "tableFrom": "agent_memberships",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_memberships_agent_id_agents_id_fk": {
+ "name": "agent_memberships_agent_id_agents_id_fk",
+ "tableFrom": "agent_memberships",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_runtime_state": {
+ "name": "agent_runtime_state",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "adapter_type": {
+ "name": "adapter_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_id": {
+ "name": "session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state_json": {
+ "name": "state_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "last_run_id": {
+ "name": "last_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_run_status": {
+ "name": "last_run_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "total_input_tokens": {
+ "name": "total_input_tokens",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_output_tokens": {
+ "name": "total_output_tokens",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_cached_input_tokens": {
+ "name": "total_cached_input_tokens",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "total_cost_cents": {
+ "name": "total_cost_cents",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_runtime_state_company_agent_idx": {
+ "name": "agent_runtime_state_company_agent_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_runtime_state_company_updated_idx": {
+ "name": "agent_runtime_state_company_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_runtime_state_agent_id_agents_id_fk": {
+ "name": "agent_runtime_state_agent_id_agents_id_fk",
+ "tableFrom": "agent_runtime_state",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agent_runtime_state_company_id_companies_id_fk": {
+ "name": "agent_runtime_state_company_id_companies_id_fk",
+ "tableFrom": "agent_runtime_state",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_task_sessions": {
+ "name": "agent_task_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "adapter_type": {
+ "name": "adapter_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "task_key": {
+ "name": "task_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "session_params_json": {
+ "name": "session_params_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_display_id": {
+ "name": "session_display_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_run_id": {
+ "name": "last_run_id",
+ "type": "uuid",
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_task_sessions_company_agent_adapter_task_uniq": {
+ "name": "agent_task_sessions_company_agent_adapter_task_uniq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "adapter_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "task_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_task_sessions_company_agent_updated_idx": {
+ "name": "agent_task_sessions_company_agent_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_task_sessions_company_task_updated_idx": {
+ "name": "agent_task_sessions_company_task_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "task_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_task_sessions_company_id_companies_id_fk": {
+ "name": "agent_task_sessions_company_id_companies_id_fk",
+ "tableFrom": "agent_task_sessions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agent_task_sessions_agent_id_agents_id_fk": {
+ "name": "agent_task_sessions_agent_id_agents_id_fk",
+ "tableFrom": "agent_task_sessions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agent_task_sessions_last_run_id_heartbeat_runs_id_fk": {
+ "name": "agent_task_sessions_last_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "agent_task_sessions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "last_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_wakeup_requests": {
+ "name": "agent_wakeup_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trigger_detail": {
+ "name": "trigger_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "coalesced_count": {
+ "name": "coalesced_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "requested_by_actor_type": {
+ "name": "requested_by_actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_by_actor_id": {
+ "name": "requested_by_actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_at": {
+ "name": "requested_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
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "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": {
+ "agent_wakeup_requests_company_agent_status_idx": {
+ "name": "agent_wakeup_requests_company_agent_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_wakeup_requests_company_requested_idx": {
+ "name": "agent_wakeup_requests_company_requested_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "requested_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_wakeup_requests_agent_requested_idx": {
+ "name": "agent_wakeup_requests_agent_requested_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "requested_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_wakeup_requests_review_path_recovery_idempotency_uq": {
+ "name": "agent_wakeup_requests_review_path_recovery_idempotency_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'issue_review_path_lost:%' AND \"agent_wakeup_requests\".\"status\" <> 'skipped'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agent_wakeup_requests_company_payload_issue_idx": {
+ "name": "agent_wakeup_requests_company_payload_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "(\"payload\" ->> 'issueId')",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_wakeup_requests_company_id_companies_id_fk": {
+ "name": "agent_wakeup_requests_company_id_companies_id_fk",
+ "tableFrom": "agent_wakeup_requests",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agent_wakeup_requests_agent_id_agents_id_fk": {
+ "name": "agent_wakeup_requests_agent_id_agents_id_fk",
+ "tableFrom": "agent_wakeup_requests",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agents": {
+ "name": "agents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'general'"
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "reports_to": {
+ "name": "reports_to",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "adapter_type": {
+ "name": "adapter_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'process'"
+ },
+ "adapter_config": {
+ "name": "adapter_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "runtime_config": {
+ "name": "runtime_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "default_environment_id": {
+ "name": "default_environment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "budget_monthly_cents": {
+ "name": "budget_monthly_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "spent_monthly_cents": {
+ "name": "spent_monthly_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "pause_reason": {
+ "name": "pause_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_at": {
+ "name": "paused_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_reason": {
+ "name": "error_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "permissions": {
+ "name": "permissions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "last_heartbeat_at": {
+ "name": "last_heartbeat_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "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": {
+ "agents_company_status_idx": {
+ "name": "agents_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agents_company_reports_to_idx": {
+ "name": "agents_company_reports_to_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "reports_to",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "agents_company_default_environment_idx": {
+ "name": "agents_company_default_environment_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "default_environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agents_company_id_companies_id_fk": {
+ "name": "agents_company_id_companies_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agents_reports_to_agents_id_fk": {
+ "name": "agents_reports_to_agents_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "reports_to"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "agents_default_environment_id_environments_id_fk": {
+ "name": "agents_default_environment_id_environments_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "default_environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.approval_comments": {
+ "name": "approval_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "approval_id": {
+ "name": "approval_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_agent_id": {
+ "name": "author_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_user_id": {
+ "name": "author_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "approval_comments_company_idx": {
+ "name": "approval_comments_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "approval_comments_approval_idx": {
+ "name": "approval_comments_approval_idx",
+ "columns": [
+ {
+ "expression": "approval_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "approval_comments_approval_created_idx": {
+ "name": "approval_comments_approval_created_idx",
+ "columns": [
+ {
+ "expression": "approval_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "approval_comments_company_id_companies_id_fk": {
+ "name": "approval_comments_company_id_companies_id_fk",
+ "tableFrom": "approval_comments",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "approval_comments_approval_id_approvals_id_fk": {
+ "name": "approval_comments_approval_id_approvals_id_fk",
+ "tableFrom": "approval_comments",
+ "tableTo": "approvals",
+ "columnsFrom": [
+ "approval_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "approval_comments_author_agent_id_agents_id_fk": {
+ "name": "approval_comments_author_agent_id_agents_id_fk",
+ "tableFrom": "approval_comments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "author_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.approvals": {
+ "name": "approvals",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_by_agent_id": {
+ "name": "requested_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_by_user_id": {
+ "name": "requested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "decision_note": {
+ "name": "decision_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_user_id": {
+ "name": "decided_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "approvals_company_status_type_idx": {
+ "name": "approvals_company_status_type_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "approvals_company_id_companies_id_fk": {
+ "name": "approvals_company_id_companies_id_fk",
+ "tableFrom": "approvals",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "approvals_requested_by_agent_id_agents_id_fk": {
+ "name": "approvals_requested_by_agent_id_agents_id_fk",
+ "tableFrom": "approvals",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "requested_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.assets": {
+ "name": "assets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "object_key": {
+ "name": "object_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "byte_size": {
+ "name": "byte_size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sha256": {
+ "name": "sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "original_filename": {
+ "name": "original_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "assets_company_created_idx": {
+ "name": "assets_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "assets_company_provider_idx": {
+ "name": "assets_company_provider_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "assets_company_object_key_uq": {
+ "name": "assets_company_object_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "object_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "assets_company_id_companies_id_fk": {
+ "name": "assets_company_id_companies_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "assets_created_by_agent_id_agents_id_fk": {
+ "name": "assets_created_by_agent_id_agents_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_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
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_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
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "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
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "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": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.board_api_keys": {
+ "name": "board_api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_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": {
+ "board_api_keys_key_hash_idx": {
+ "name": "board_api_keys_key_hash_idx",
+ "columns": [
+ {
+ "expression": "key_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "board_api_keys_user_idx": {
+ "name": "board_api_keys_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "board_api_keys_user_id_user_id_fk": {
+ "name": "board_api_keys_user_id_user_id_fk",
+ "tableFrom": "board_api_keys",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.budget_incidents": {
+ "name": "budget_incidents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "policy_id": {
+ "name": "policy_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_type": {
+ "name": "scope_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "window_kind": {
+ "name": "window_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "window_start": {
+ "name": "window_start",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "window_end": {
+ "name": "window_end",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "threshold_type": {
+ "name": "threshold_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount_limit": {
+ "name": "amount_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount_observed": {
+ "name": "amount_observed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "approval_id": {
+ "name": "approval_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "budget_incidents_company_status_idx": {
+ "name": "budget_incidents_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "budget_incidents_company_scope_idx": {
+ "name": "budget_incidents_company_scope_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "budget_incidents_policy_window_threshold_idx": {
+ "name": "budget_incidents_policy_window_threshold_idx",
+ "columns": [
+ {
+ "expression": "policy_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "window_start",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "threshold_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"budget_incidents\".\"status\" <> 'dismissed'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "budget_incidents_company_id_companies_id_fk": {
+ "name": "budget_incidents_company_id_companies_id_fk",
+ "tableFrom": "budget_incidents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "budget_incidents_policy_id_budget_policies_id_fk": {
+ "name": "budget_incidents_policy_id_budget_policies_id_fk",
+ "tableFrom": "budget_incidents",
+ "tableTo": "budget_policies",
+ "columnsFrom": [
+ "policy_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "budget_incidents_approval_id_approvals_id_fk": {
+ "name": "budget_incidents_approval_id_approvals_id_fk",
+ "tableFrom": "budget_incidents",
+ "tableTo": "approvals",
+ "columnsFrom": [
+ "approval_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.budget_policies": {
+ "name": "budget_policies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_type": {
+ "name": "scope_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'billed_cents'"
+ },
+ "window_kind": {
+ "name": "window_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "amount": {
+ "name": "amount",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "warn_percent": {
+ "name": "warn_percent",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 80
+ },
+ "hard_stop_enabled": {
+ "name": "hard_stop_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "notify_enabled": {
+ "name": "notify_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "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": {
+ "budget_policies_company_scope_active_idx": {
+ "name": "budget_policies_company_scope_active_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_active",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "budget_policies_company_window_idx": {
+ "name": "budget_policies_company_window_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "window_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "metric",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "budget_policies_company_scope_metric_unique_idx": {
+ "name": "budget_policies_company_scope_metric_unique_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "metric",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "window_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "budget_policies_company_id_companies_id_fk": {
+ "name": "budget_policies_company_id_companies_id_fk",
+ "tableFrom": "budget_policies",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.built_in_managed_resources": {
+ "name": "built_in_managed_resources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bundle_key": {
+ "name": "bundle_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_kind": {
+ "name": "resource_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_key": {
+ "name": "resource_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stock_version": {
+ "name": "stock_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stock_hash": {
+ "name": "stock_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "defaults_json": {
+ "name": "defaults_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "built_in_managed_resources_company_idx": {
+ "name": "built_in_managed_resources_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "built_in_managed_resources_resource_idx": {
+ "name": "built_in_managed_resources_resource_idx",
+ "columns": [
+ {
+ "expression": "resource_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "built_in_managed_resources_company_bundle_resource_uq": {
+ "name": "built_in_managed_resources_company_bundle_resource_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "bundle_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "built_in_managed_resources_company_id_companies_id_fk": {
+ "name": "built_in_managed_resources_company_id_companies_id_fk",
+ "tableFrom": "built_in_managed_resources",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.case_attachments": {
+ "name": "case_attachments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "asset_id": {
+ "name": "asset_id",
+ "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": {
+ "case_attachments_company_case_idx": {
+ "name": "case_attachments_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_attachments_asset_uq": {
+ "name": "case_attachments_asset_uq",
+ "columns": [
+ {
+ "expression": "asset_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_attachments_company_id_companies_id_fk": {
+ "name": "case_attachments_company_id_companies_id_fk",
+ "tableFrom": "case_attachments",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_attachments_case_id_cases_id_fk": {
+ "name": "case_attachments_case_id_cases_id_fk",
+ "tableFrom": "case_attachments",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_attachments_asset_id_assets_id_fk": {
+ "name": "case_attachments_asset_id_assets_id_fk",
+ "tableFrom": "case_attachments",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "asset_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.case_documents": {
+ "name": "case_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "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": {
+ "case_documents_company_case_key_uq": {
+ "name": "case_documents_company_case_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_documents_document_uq": {
+ "name": "case_documents_document_uq",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_documents_company_case_updated_idx": {
+ "name": "case_documents_company_case_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_documents_company_id_companies_id_fk": {
+ "name": "case_documents_company_id_companies_id_fk",
+ "tableFrom": "case_documents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_documents_case_id_cases_id_fk": {
+ "name": "case_documents_case_id_cases_id_fk",
+ "tableFrom": "case_documents",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_documents_document_id_documents_id_fk": {
+ "name": "case_documents_document_id_documents_id_fk",
+ "tableFrom": "case_documents",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.case_events": {
+ "name": "case_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_agent_id": {
+ "name": "actor_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "case_events_case_created_idx": {
+ "name": "case_events_case_created_idx",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_events_company_case_idx": {
+ "name": "case_events_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_events_company_id_companies_id_fk": {
+ "name": "case_events_company_id_companies_id_fk",
+ "tableFrom": "case_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_events_case_id_cases_id_fk": {
+ "name": "case_events_case_id_cases_id_fk",
+ "tableFrom": "case_events",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_events_actor_agent_id_agents_id_fk": {
+ "name": "case_events_actor_agent_id_agents_id_fk",
+ "tableFrom": "case_events",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "actor_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "case_events_kind_check": {
+ "name": "case_events_kind_check",
+ "value": "\"case_events\".\"kind\" in (\n 'created',\n 'updated',\n 'fields_changed',\n 'status_changed',\n 'issue_linked',\n 'issue_unlinked',\n 'document_revised',\n 'child_linked',\n 'attachment_added',\n 'label_added',\n 'label_removed'\n )"
+ },
+ "case_events_actor_type_check": {
+ "name": "case_events_actor_type_check",
+ "value": "\"case_events\".\"actor_type\" in ('user', 'agent', 'system')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.case_issue_links": {
+ "name": "case_issue_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "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": {
+ "case_issue_links_case_issue_uq": {
+ "name": "case_issue_links_case_issue_uq",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_issue_links_company_case_idx": {
+ "name": "case_issue_links_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_issue_links_issue_idx": {
+ "name": "case_issue_links_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_issue_links_company_id_companies_id_fk": {
+ "name": "case_issue_links_company_id_companies_id_fk",
+ "tableFrom": "case_issue_links",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_issue_links_case_id_cases_id_fk": {
+ "name": "case_issue_links_case_id_cases_id_fk",
+ "tableFrom": "case_issue_links",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_issue_links_issue_id_issues_id_fk": {
+ "name": "case_issue_links_issue_id_issues_id_fk",
+ "tableFrom": "case_issue_links",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "case_issue_links_role_check": {
+ "name": "case_issue_links_role_check",
+ "value": "\"case_issue_links\".\"role\" in ('origin', 'work', 'reference')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.case_labels": {
+ "name": "case_labels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_id": {
+ "name": "label_id",
+ "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": {
+ "case_labels_case_label_uq": {
+ "name": "case_labels_case_label_uq",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "label_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_labels_company_case_idx": {
+ "name": "case_labels_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "case_labels_label_idx": {
+ "name": "case_labels_label_idx",
+ "columns": [
+ {
+ "expression": "label_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "case_labels_company_id_companies_id_fk": {
+ "name": "case_labels_company_id_companies_id_fk",
+ "tableFrom": "case_labels",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_labels_case_id_cases_id_fk": {
+ "name": "case_labels_case_id_cases_id_fk",
+ "tableFrom": "case_labels",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "case_labels_label_id_labels_id_fk": {
+ "name": "case_labels_label_id_labels_id_fk",
+ "tableFrom": "case_labels",
+ "tableTo": "labels",
+ "columnsFrom": [
+ "label_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cases": {
+ "name": "cases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_number": {
+ "name": "case_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_type": {
+ "name": "case_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "fields": {
+ "name": "fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "parent_case_id": {
+ "name": "parent_case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "cases_company_case_number_uq": {
+ "name": "cases_company_case_number_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_identifier_uq": {
+ "name": "cases_identifier_uq",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_company_type_key_uq": {
+ "name": "cases_company_type_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_company_status_idx": {
+ "name": "cases_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_company_type_idx": {
+ "name": "cases_company_type_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_company_project_idx": {
+ "name": "cases_company_project_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_parent_idx": {
+ "name": "cases_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cases_title_search_idx": {
+ "name": "cases_title_search_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "cases_identifier_search_idx": {
+ "name": "cases_identifier_search_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "cases_summary_search_idx": {
+ "name": "cases_summary_search_idx",
+ "columns": [
+ {
+ "expression": "summary",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "cases_company_id_companies_id_fk": {
+ "name": "cases_company_id_companies_id_fk",
+ "tableFrom": "cases",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "cases_project_id_projects_id_fk": {
+ "name": "cases_project_id_projects_id_fk",
+ "tableFrom": "cases",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "cases_parent_case_id_cases_id_fk": {
+ "name": "cases_parent_case_id_cases_id_fk",
+ "tableFrom": "cases",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "parent_case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "cases_created_by_agent_id_agents_id_fk": {
+ "name": "cases_created_by_agent_id_agents_id_fk",
+ "tableFrom": "cases",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "cases_status_check": {
+ "name": "cases_status_check",
+ "value": "\"cases\".\"status\" in ('draft', 'in_progress', 'in_review', 'approved', 'done', 'cancelled')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.cli_auth_challenges": {
+ "name": "cli_auth_challenges",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "secret_hash": {
+ "name": "secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_access": {
+ "name": "requested_access",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'board'"
+ },
+ "requested_company_id": {
+ "name": "requested_company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pending_key_hash": {
+ "name": "pending_key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pending_key_name": {
+ "name": "pending_key_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "approved_by_user_id": {
+ "name": "approved_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "board_api_key_id": {
+ "name": "board_api_key_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_at": {
+ "name": "approved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {
+ "cli_auth_challenges_secret_hash_idx": {
+ "name": "cli_auth_challenges_secret_hash_idx",
+ "columns": [
+ {
+ "expression": "secret_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cli_auth_challenges_approved_by_idx": {
+ "name": "cli_auth_challenges_approved_by_idx",
+ "columns": [
+ {
+ "expression": "approved_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cli_auth_challenges_requested_company_idx": {
+ "name": "cli_auth_challenges_requested_company_idx",
+ "columns": [
+ {
+ "expression": "requested_company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "cli_auth_challenges_requested_company_id_companies_id_fk": {
+ "name": "cli_auth_challenges_requested_company_id_companies_id_fk",
+ "tableFrom": "cli_auth_challenges",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "requested_company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "cli_auth_challenges_approved_by_user_id_user_id_fk": {
+ "name": "cli_auth_challenges_approved_by_user_id_user_id_fk",
+ "tableFrom": "cli_auth_challenges",
+ "tableTo": "user",
+ "columnsFrom": [
+ "approved_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk": {
+ "name": "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk",
+ "tableFrom": "cli_auth_challenges",
+ "tableTo": "board_api_keys",
+ "columnsFrom": [
+ "board_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.companies": {
+ "name": "companies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "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'"
+ },
+ "pause_reason": {
+ "name": "pause_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_at": {
+ "name": "paused_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_prefix": {
+ "name": "issue_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'PAP'"
+ },
+ "issue_counter": {
+ "name": "issue_counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "budget_monthly_cents": {
+ "name": "budget_monthly_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "spent_monthly_cents": {
+ "name": "spent_monthly_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "attachment_max_bytes": {
+ "name": "attachment_max_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 10485760
+ },
+ "default_responsible_user_id": {
+ "name": "default_responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "require_board_approval_for_new_agents": {
+ "name": "require_board_approval_for_new_agents",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "interaction_resolver_governance": {
+ "name": "interaction_resolver_governance",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "feedback_data_sharing_enabled": {
+ "name": "feedback_data_sharing_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "feedback_data_sharing_consent_at": {
+ "name": "feedback_data_sharing_consent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "feedback_data_sharing_consent_by_user_id": {
+ "name": "feedback_data_sharing_consent_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "feedback_data_sharing_terms_version": {
+ "name": "feedback_data_sharing_terms_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "brand_color": {
+ "name": "brand_color",
+ "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": {
+ "companies_issue_prefix_idx": {
+ "name": "companies_issue_prefix_idx",
+ "columns": [
+ {
+ "expression": "issue_prefix",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_logos": {
+ "name": "company_logos",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "asset_id": {
+ "name": "asset_id",
+ "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": {
+ "company_logos_company_uq": {
+ "name": "company_logos_company_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_logos_asset_uq": {
+ "name": "company_logos_asset_uq",
+ "columns": [
+ {
+ "expression": "asset_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_logos_company_id_companies_id_fk": {
+ "name": "company_logos_company_id_companies_id_fk",
+ "tableFrom": "company_logos",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_logos_asset_id_assets_id_fk": {
+ "name": "company_logos_asset_id_assets_id_fk",
+ "tableFrom": "company_logos",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "asset_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_memberships": {
+ "name": "company_memberships",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal_type": {
+ "name": "principal_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal_id": {
+ "name": "principal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "membership_role": {
+ "name": "membership_role",
+ "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": {
+ "company_memberships_company_principal_unique_idx": {
+ "name": "company_memberships_company_principal_unique_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_memberships_principal_status_idx": {
+ "name": "company_memberships_principal_status_idx",
+ "columns": [
+ {
+ "expression": "principal_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_memberships_company_status_idx": {
+ "name": "company_memberships_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_memberships_company_id_companies_id_fk": {
+ "name": "company_memberships_company_id_companies_id_fk",
+ "tableFrom": "company_memberships",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_secret_bindings": {
+ "name": "company_secret_bindings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_id": {
+ "name": "secret_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_path": {
+ "name": "config_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version_selector": {
+ "name": "version_selector",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'latest'"
+ },
+ "required": {
+ "name": "required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "projection_class": {
+ "name": "projection_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unclassified'"
+ },
+ "projection_allowlist_key": {
+ "name": "projection_allowlist_key",
+ "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": {
+ "company_secret_bindings_company_idx": {
+ "name": "company_secret_bindings_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_bindings_secret_idx": {
+ "name": "company_secret_bindings_secret_idx",
+ "columns": [
+ {
+ "expression": "secret_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_bindings_target_idx": {
+ "name": "company_secret_bindings_target_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_bindings_target_path_uq": {
+ "name": "company_secret_bindings_target_path_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "config_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_secret_bindings_company_id_companies_id_fk": {
+ "name": "company_secret_bindings_company_id_companies_id_fk",
+ "tableFrom": "company_secret_bindings",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "company_secret_bindings_secret_id_company_secrets_id_fk": {
+ "name": "company_secret_bindings_secret_id_company_secrets_id_fk",
+ "tableFrom": "company_secret_bindings",
+ "tableTo": "company_secrets",
+ "columnsFrom": [
+ "secret_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_secret_proposals": {
+ "name": "company_secret_proposals",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "proposed_name": {
+ "name": "proposed_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_key": {
+ "name": "proposed_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_description": {
+ "name": "proposed_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "justification": {
+ "name": "justification",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value_ciphertext": {
+ "name": "value_ciphertext",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "value_fingerprint_sha256": {
+ "name": "value_fingerprint_sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "value_length": {
+ "name": "value_length",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secret_id": {
+ "name": "secret_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secret_proposal_id": {
+ "name": "secret_proposal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config_path": {
+ "name": "config_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "projection_class": {
+ "name": "projection_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unclassified'"
+ },
+ "binding_target_policy_snapshot": {
+ "name": "binding_target_policy_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposer_ancestor_ids_snapshot": {
+ "name": "proposer_ancestor_ids_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_ancestor_ids_snapshot": {
+ "name": "target_ancestor_ids_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "proposed_by_agent_id": {
+ "name": "proposed_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_issue_id": {
+ "name": "origin_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_run_id": {
+ "name": "origin_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resolved_by_user_id": {
+ "name": "resolved_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolution_reason": {
+ "name": "resolution_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_secret_id": {
+ "name": "created_secret_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "applied_binding_config_path": {
+ "name": "applied_binding_config_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ciphertext_scrubbed_at": {
+ "name": "ciphertext_scrubbed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {
+ "company_secret_proposals_company_status_idx": {
+ "name": "company_secret_proposals_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_proposals_proposer_status_idx": {
+ "name": "company_secret_proposals_proposer_status_idx",
+ "columns": [
+ {
+ "expression": "proposed_by_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_proposals_expiry_idx": {
+ "name": "company_secret_proposals_expiry_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_proposals_secret_proposal_idx": {
+ "name": "company_secret_proposals_secret_proposal_idx",
+ "columns": [
+ {
+ "expression": "secret_proposal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_secret_proposals_company_id_companies_id_fk": {
+ "name": "company_secret_proposals_company_id_companies_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "company_secret_proposals_secret_id_company_secrets_id_fk": {
+ "name": "company_secret_proposals_secret_id_company_secrets_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "company_secrets",
+ "columnsFrom": [
+ "secret_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk": {
+ "name": "company_secret_proposals_secret_proposal_id_company_secret_proposals_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "company_secret_proposals",
+ "columnsFrom": [
+ "secret_proposal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_secret_proposals_target_id_agents_id_fk": {
+ "name": "company_secret_proposals_target_id_agents_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "target_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_secret_proposals_proposed_by_agent_id_agents_id_fk": {
+ "name": "company_secret_proposals_proposed_by_agent_id_agents_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "proposed_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_secret_proposals_origin_issue_id_issues_id_fk": {
+ "name": "company_secret_proposals_origin_issue_id_issues_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "origin_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk": {
+ "name": "company_secret_proposals_origin_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "origin_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_secret_proposals_created_secret_id_company_secrets_id_fk": {
+ "name": "company_secret_proposals_created_secret_id_company_secrets_id_fk",
+ "tableFrom": "company_secret_proposals",
+ "tableTo": "company_secrets",
+ "columnsFrom": [
+ "created_secret_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "company_secret_proposals_kind_check": {
+ "name": "company_secret_proposals_kind_check",
+ "value": "\"company_secret_proposals\".\"kind\" in ('secret', 'binding')"
+ },
+ "company_secret_proposals_status_check": {
+ "name": "company_secret_proposals_status_check",
+ "value": "\"company_secret_proposals\".\"status\" in ('pending', 'approved', 'rejected', 'withdrawn', 'expired')"
+ },
+ "company_secret_proposals_projection_check": {
+ "name": "company_secret_proposals_projection_check",
+ "value": "\"company_secret_proposals\".\"projection_class\" = 'unclassified'"
+ },
+ "company_secret_proposals_shape_check": {
+ "name": "company_secret_proposals_shape_check",
+ "value": "(\n \"company_secret_proposals\".\"kind\" = 'secret'\n and \"company_secret_proposals\".\"proposed_name\" is not null\n and \"company_secret_proposals\".\"proposed_key\" is not null\n and \"company_secret_proposals\".\"secret_id\" is null\n and \"company_secret_proposals\".\"secret_proposal_id\" is null\n and \"company_secret_proposals\".\"target_type\" is null\n and \"company_secret_proposals\".\"target_id\" is null\n and \"company_secret_proposals\".\"config_path\" is null\n ) or (\n \"company_secret_proposals\".\"kind\" = 'binding'\n and ((\"company_secret_proposals\".\"secret_id\" is not null)::int + (\"company_secret_proposals\".\"secret_proposal_id\" is not null)::int) = 1\n and \"company_secret_proposals\".\"target_type\" = 'agent'\n and \"company_secret_proposals\".\"target_id\" is not null\n and \"company_secret_proposals\".\"config_path\" is not null\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.company_secret_provider_configs": {
+ "name": "company_secret_provider_configs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ready'"
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "health_status": {
+ "name": "health_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "health_checked_at": {
+ "name": "health_checked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "health_message": {
+ "name": "health_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "health_details": {
+ "name": "health_details",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disabled_at": {
+ "name": "disabled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "company_secret_provider_configs_company_idx": {
+ "name": "company_secret_provider_configs_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_provider_configs_company_provider_idx": {
+ "name": "company_secret_provider_configs_company_provider_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_provider_configs_default_uq": {
+ "name": "company_secret_provider_configs_default_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"company_secret_provider_configs\".\"is_default\" = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_secret_provider_configs_company_id_companies_id_fk": {
+ "name": "company_secret_provider_configs_company_id_companies_id_fk",
+ "tableFrom": "company_secret_provider_configs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_secret_provider_configs_created_by_agent_id_agents_id_fk": {
+ "name": "company_secret_provider_configs_created_by_agent_id_agents_id_fk",
+ "tableFrom": "company_secret_provider_configs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_secret_versions": {
+ "name": "company_secret_versions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "secret_id": {
+ "name": "secret_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "material": {
+ "name": "material",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value_sha256": {
+ "name": "value_sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_version_ref": {
+ "name": "provider_version_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'current'"
+ },
+ "fingerprint_sha256": {
+ "name": "fingerprint_sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rotation_job_id": {
+ "name": "rotation_job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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
+ }
+ },
+ "indexes": {
+ "company_secret_versions_secret_idx": {
+ "name": "company_secret_versions_secret_idx",
+ "columns": [
+ {
+ "expression": "secret_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_versions_value_sha256_idx": {
+ "name": "company_secret_versions_value_sha256_idx",
+ "columns": [
+ {
+ "expression": "value_sha256",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_versions_fingerprint_idx": {
+ "name": "company_secret_versions_fingerprint_idx",
+ "columns": [
+ {
+ "expression": "fingerprint_sha256",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secret_versions_secret_version_uq": {
+ "name": "company_secret_versions_secret_version_uq",
+ "columns": [
+ {
+ "expression": "secret_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_secret_versions_secret_id_company_secrets_id_fk": {
+ "name": "company_secret_versions_secret_id_company_secrets_id_fk",
+ "tableFrom": "company_secret_versions",
+ "tableTo": "company_secrets",
+ "columnsFrom": [
+ "secret_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_secret_versions_created_by_agent_id_agents_id_fk": {
+ "name": "company_secret_versions_created_by_agent_id_agents_id_fk",
+ "tableFrom": "company_secret_versions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_secrets": {
+ "name": "company_secrets",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'company'"
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_secret_definition_id": {
+ "name": "user_secret_definition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local_encrypted'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "managed_mode": {
+ "name": "managed_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'paperclip_managed'"
+ },
+ "external_ref": {
+ "name": "external_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_config_id": {
+ "name": "provider_config_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_metadata": {
+ "name": "provider_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latest_version": {
+ "name": "latest_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_resolved_at": {
+ "name": "last_resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_rotated_at": {
+ "name": "last_rotated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "company_secrets_company_idx": {
+ "name": "company_secrets_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_company_scope_idx": {
+ "name": "company_secrets_company_scope_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_company_owner_idx": {
+ "name": "company_secrets_company_owner_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_user_definition_owner_idx": {
+ "name": "company_secrets_user_definition_owner_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_secret_definition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_company_provider_idx": {
+ "name": "company_secrets_company_provider_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_provider_config_idx": {
+ "name": "company_secrets_provider_config_idx",
+ "columns": [
+ {
+ "expression": "provider_config_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_company_name_uq": {
+ "name": "company_secrets_company_name_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_company_key_uq": {
+ "name": "company_secrets_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"company_secrets\".\"scope\" = 'company' and \"company_secrets\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_secrets_user_definition_owner_uq": {
+ "name": "company_secrets_user_definition_owner_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_secret_definition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"company_secrets\".\"scope\" = 'user' and \"company_secrets\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_secrets_company_id_companies_id_fk": {
+ "name": "company_secrets_company_id_companies_id_fk",
+ "tableFrom": "company_secrets",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk": {
+ "name": "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk",
+ "tableFrom": "company_secrets",
+ "tableTo": "user_secret_definitions",
+ "columnsFrom": [
+ "user_secret_definition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_secrets_provider_config_id_company_secret_provider_configs_id_fk": {
+ "name": "company_secrets_provider_config_id_company_secret_provider_configs_id_fk",
+ "tableFrom": "company_secrets",
+ "tableTo": "company_secret_provider_configs",
+ "columnsFrom": [
+ "provider_config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_secrets_created_by_agent_id_agents_id_fk": {
+ "name": "company_secrets_created_by_agent_id_agents_id_fk",
+ "tableFrom": "company_secrets",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "company_secrets_scope_shape_check": {
+ "name": "company_secrets_scope_shape_check",
+ "value": "(\n \"company_secrets\".\"scope\" = 'company'\n and \"company_secrets\".\"owner_user_id\" is null\n and \"company_secrets\".\"user_secret_definition_id\" is null\n ) or (\n \"company_secrets\".\"scope\" = 'user'\n and \"company_secrets\".\"owner_user_id\" is not null\n and \"company_secrets\".\"user_secret_definition_id\" is not null\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.company_skill_policies": {
+ "name": "company_skill_policies",
+ "schema": "",
+ "columns": {
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "schema_version": {
+ "name": "schema_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "default_effect": {
+ "name": "default_effect",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rules": {
+ "name": "rules",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "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": {
+ "company_skill_policies_company_id_companies_id_fk": {
+ "name": "company_skill_policies_company_id_companies_id_fk",
+ "tableFrom": "company_skill_policies",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skill_comments": {
+ "name": "company_skill_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_skill_id": {
+ "name": "company_skill_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_comment_id": {
+ "name": "parent_comment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_agent_id": {
+ "name": "author_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_user_id": {
+ "name": "author_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deleted_at": {
+ "name": "deleted_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_skill_comments_company_skill_created_idx": {
+ "name": "company_skill_comments_company_skill_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "company_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_comments_parent_idx": {
+ "name": "company_skill_comments_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_comment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_comments_company_id_companies_id_fk": {
+ "name": "company_skill_comments_company_id_companies_id_fk",
+ "tableFrom": "company_skill_comments",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_comments_company_skill_id_company_skills_id_fk": {
+ "name": "company_skill_comments_company_skill_id_company_skills_id_fk",
+ "tableFrom": "company_skill_comments",
+ "tableTo": "company_skills",
+ "columnsFrom": [
+ "company_skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_comments_parent_comment_id_company_skill_comments_id_fk": {
+ "name": "company_skill_comments_parent_comment_id_company_skill_comments_id_fk",
+ "tableFrom": "company_skill_comments",
+ "tableTo": "company_skill_comments",
+ "columnsFrom": [
+ "parent_comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_skill_comments_author_agent_id_agents_id_fk": {
+ "name": "company_skill_comments_author_agent_id_agents_id_fk",
+ "tableFrom": "company_skill_comments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "author_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skill_stars": {
+ "name": "company_skill_stars",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_skill_id": {
+ "name": "company_skill_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_skill_stars_skill_agent_idx": {
+ "name": "company_skill_stars_skill_agent_idx",
+ "columns": [
+ {
+ "expression": "company_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_stars_skill_user_idx": {
+ "name": "company_skill_stars_skill_user_idx",
+ "columns": [
+ {
+ "expression": "company_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_stars_company_skill_created_idx": {
+ "name": "company_skill_stars_company_skill_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "company_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_stars_company_id_companies_id_fk": {
+ "name": "company_skill_stars_company_id_companies_id_fk",
+ "tableFrom": "company_skill_stars",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_stars_company_skill_id_company_skills_id_fk": {
+ "name": "company_skill_stars_company_skill_id_company_skills_id_fk",
+ "tableFrom": "company_skill_stars",
+ "tableTo": "company_skills",
+ "columnsFrom": [
+ "company_skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_stars_agent_id_agents_id_fk": {
+ "name": "company_skill_stars_agent_id_agents_id_fk",
+ "tableFrom": "company_skill_stars",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skill_test_inputs": {
+ "name": "company_skill_test_inputs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "skill_id": {
+ "name": "skill_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_skill_test_inputs_company_skill_name_idx": {
+ "name": "company_skill_test_inputs_company_skill_name_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_test_inputs_company_skill_active_idx": {
+ "name": "company_skill_test_inputs_company_skill_active_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_test_inputs_company_id_companies_id_fk": {
+ "name": "company_skill_test_inputs_company_id_companies_id_fk",
+ "tableFrom": "company_skill_test_inputs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_inputs_skill_id_company_skills_id_fk": {
+ "name": "company_skill_test_inputs_skill_id_company_skills_id_fk",
+ "tableFrom": "company_skill_test_inputs",
+ "tableTo": "company_skills",
+ "columnsFrom": [
+ "skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skill_test_run_templates": {
+ "name": "company_skill_test_run_templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_agent_id": {
+ "name": "updated_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_skill_test_run_templates_company_active_idx": {
+ "name": "company_skill_test_run_templates_company_active_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_test_run_templates_company_id_companies_id_fk": {
+ "name": "company_skill_test_run_templates_company_id_companies_id_fk",
+ "tableFrom": "company_skill_test_run_templates",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_run_templates_created_by_agent_id_agents_id_fk": {
+ "name": "company_skill_test_run_templates_created_by_agent_id_agents_id_fk",
+ "tableFrom": "company_skill_test_run_templates",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk": {
+ "name": "company_skill_test_run_templates_updated_by_agent_id_agents_id_fk",
+ "tableFrom": "company_skill_test_run_templates",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "updated_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skill_test_runs": {
+ "name": "company_skill_test_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "skill_id": {
+ "name": "skill_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input_id": {
+ "name": "input_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_snapshot": {
+ "name": "input_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "skill_version_id": {
+ "name": "skill_version_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_config_snapshot": {
+ "name": "agent_config_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "template_name": {
+ "name": "template_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "template_body": {
+ "name": "template_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rendered_template_body": {
+ "name": "rendered_template_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "harness_issue_description": {
+ "name": "harness_issue_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "output_document_key": {
+ "name": "output_document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'output'"
+ },
+ "output_snapshot": {
+ "name": "output_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "superseded_at": {
+ "name": "superseded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "harness_issue_expires_at": {
+ "name": "harness_issue_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "harness_issue_deleted_at": {
+ "name": "harness_issue_deleted_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_skill_test_runs_company_skill_created_idx": {
+ "name": "company_skill_test_runs_company_skill_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_test_runs_company_issue_idx": {
+ "name": "company_skill_test_runs_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_test_runs_company_input_created_idx": {
+ "name": "company_skill_test_runs_company_input_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "input_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_test_runs_company_status_idx": {
+ "name": "company_skill_test_runs_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_test_runs_company_harness_expires_idx": {
+ "name": "company_skill_test_runs_company_harness_expires_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "harness_issue_expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_test_runs_company_id_companies_id_fk": {
+ "name": "company_skill_test_runs_company_id_companies_id_fk",
+ "tableFrom": "company_skill_test_runs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_runs_skill_id_company_skills_id_fk": {
+ "name": "company_skill_test_runs_skill_id_company_skills_id_fk",
+ "tableFrom": "company_skill_test_runs",
+ "tableTo": "company_skills",
+ "columnsFrom": [
+ "skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk": {
+ "name": "company_skill_test_runs_input_id_company_skill_test_inputs_id_fk",
+ "tableFrom": "company_skill_test_runs",
+ "tableTo": "company_skill_test_inputs",
+ "columnsFrom": [
+ "input_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk": {
+ "name": "company_skill_test_runs_skill_version_id_company_skill_versions_id_fk",
+ "tableFrom": "company_skill_test_runs",
+ "tableTo": "company_skill_versions",
+ "columnsFrom": [
+ "skill_version_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_runs_agent_id_agents_id_fk": {
+ "name": "company_skill_test_runs_agent_id_agents_id_fk",
+ "tableFrom": "company_skill_test_runs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "company_skill_test_runs_issue_id_issues_id_fk": {
+ "name": "company_skill_test_runs_issue_id_issues_id_fk",
+ "tableFrom": "company_skill_test_runs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skill_versions": {
+ "name": "company_skill_versions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_skill_id": {
+ "name": "company_skill_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revision_number": {
+ "name": "revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_id": {
+ "name": "release_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_name": {
+ "name": "release_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "released_at": {
+ "name": "released_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "file_inventory": {
+ "name": "file_inventory",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "author_agent_id": {
+ "name": "author_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_user_id": {
+ "name": "author_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "company_skill_versions_skill_revision_idx": {
+ "name": "company_skill_versions_skill_revision_idx",
+ "columns": [
+ {
+ "expression": "company_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "revision_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_versions_skill_release_idx": {
+ "name": "company_skill_versions_skill_release_idx",
+ "columns": [
+ {
+ "expression": "company_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "release_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"company_skill_versions\".\"release_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skill_versions_company_skill_created_idx": {
+ "name": "company_skill_versions_company_skill_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "company_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skill_versions_company_id_companies_id_fk": {
+ "name": "company_skill_versions_company_id_companies_id_fk",
+ "tableFrom": "company_skill_versions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_versions_company_skill_id_company_skills_id_fk": {
+ "name": "company_skill_versions_company_skill_id_company_skills_id_fk",
+ "tableFrom": "company_skill_versions",
+ "tableTo": "company_skills",
+ "columnsFrom": [
+ "company_skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "company_skill_versions_author_agent_id_agents_id_fk": {
+ "name": "company_skill_versions_author_agent_id_agents_id_fk",
+ "tableFrom": "company_skill_versions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "author_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_skills": {
+ "name": "company_skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder_id": {
+ "name": "folder_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "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
+ },
+ "markdown": {
+ "name": "markdown",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_type": {
+ "name": "source_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local_path'"
+ },
+ "source_locator": {
+ "name": "source_locator",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_ref": {
+ "name": "source_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trust_level": {
+ "name": "trust_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'markdown_only'"
+ },
+ "compatibility": {
+ "name": "compatibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'compatible'"
+ },
+ "file_inventory": {
+ "name": "file_inventory",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tagline": {
+ "name": "tagline",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_name": {
+ "name": "author_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "homepage_url": {
+ "name": "homepage_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "categories": {
+ "name": "categories",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "sharing_scope": {
+ "name": "sharing_scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'company'"
+ },
+ "public_share_token": {
+ "name": "public_share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "forked_from_skill_id": {
+ "name": "forked_from_skill_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "forked_from_company_id": {
+ "name": "forked_from_company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "star_count": {
+ "name": "star_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "install_count": {
+ "name": "install_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "fork_count": {
+ "name": "fork_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "current_version_id": {
+ "name": "current_version_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "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": {
+ "company_skills_company_key_idx": {
+ "name": "company_skills_company_key_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skills_company_name_idx": {
+ "name": "company_skills_company_name_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skills_company_folder_idx": {
+ "name": "company_skills_company_folder_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skills_company_categories_idx": {
+ "name": "company_skills_company_categories_idx",
+ "columns": [
+ {
+ "expression": "categories",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "company_skills_company_sharing_scope_idx": {
+ "name": "company_skills_company_sharing_scope_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "sharing_scope",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skills_company_current_version_idx": {
+ "name": "company_skills_company_current_version_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "current_version_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_skills_company_forked_from_idx": {
+ "name": "company_skills_company_forked_from_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "forked_from_skill_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_skills_company_id_companies_id_fk": {
+ "name": "company_skills_company_id_companies_id_fk",
+ "tableFrom": "company_skills",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "company_skills_folder_id_folders_id_fk": {
+ "name": "company_skills_folder_id_folders_id_fk",
+ "tableFrom": "company_skills",
+ "tableTo": "folders",
+ "columnsFrom": [
+ "folder_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_skills_forked_from_skill_id_company_skills_id_fk": {
+ "name": "company_skills_forked_from_skill_id_company_skills_id_fk",
+ "tableFrom": "company_skills",
+ "tableTo": "company_skills",
+ "columnsFrom": [
+ "forked_from_skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_skills_forked_from_company_id_companies_id_fk": {
+ "name": "company_skills_forked_from_company_id_companies_id_fk",
+ "tableFrom": "company_skills",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "forked_from_company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "company_skills_current_version_id_company_skill_versions_id_fk": {
+ "name": "company_skills_current_version_id_company_skill_versions_id_fk",
+ "tableFrom": "company_skills",
+ "tableTo": "company_skill_versions",
+ "columnsFrom": [
+ "current_version_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.company_user_sidebar_preferences": {
+ "name": "company_user_sidebar_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_order": {
+ "name": "project_order",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "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": {
+ "company_user_sidebar_preferences_company_idx": {
+ "name": "company_user_sidebar_preferences_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_user_sidebar_preferences_user_idx": {
+ "name": "company_user_sidebar_preferences_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "company_user_sidebar_preferences_company_user_uq": {
+ "name": "company_user_sidebar_preferences_company_user_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "company_user_sidebar_preferences_company_id_companies_id_fk": {
+ "name": "company_user_sidebar_preferences_company_id_companies_id_fk",
+ "tableFrom": "company_user_sidebar_preferences",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cost_events": {
+ "name": "cost_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "goal_id": {
+ "name": "goal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heartbeat_run_id": {
+ "name": "heartbeat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billing_code": {
+ "name": "billing_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "biller": {
+ "name": "biller",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "billing_type": {
+ "name": "billing_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "cost_status": {
+ "name": "cost_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'reported'"
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cached_input_tokens": {
+ "name": "cached_input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cost_cents": {
+ "name": "cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "occurred_at": {
+ "name": "occurred_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()"
+ }
+ },
+ "indexes": {
+ "cost_events_company_occurred_idx": {
+ "name": "cost_events_company_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cost_events_company_agent_occurred_idx": {
+ "name": "cost_events_company_agent_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cost_events_company_provider_occurred_idx": {
+ "name": "cost_events_company_provider_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cost_events_company_biller_occurred_idx": {
+ "name": "cost_events_company_biller_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "biller",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "cost_events_company_heartbeat_run_idx": {
+ "name": "cost_events_company_heartbeat_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "heartbeat_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "cost_events_company_id_companies_id_fk": {
+ "name": "cost_events_company_id_companies_id_fk",
+ "tableFrom": "cost_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "cost_events_agent_id_agents_id_fk": {
+ "name": "cost_events_agent_id_agents_id_fk",
+ "tableFrom": "cost_events",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "cost_events_issue_id_issues_id_fk": {
+ "name": "cost_events_issue_id_issues_id_fk",
+ "tableFrom": "cost_events",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "cost_events_project_id_projects_id_fk": {
+ "name": "cost_events_project_id_projects_id_fk",
+ "tableFrom": "cost_events",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "cost_events_goal_id_goals_id_fk": {
+ "name": "cost_events_goal_id_goals_id_fk",
+ "tableFrom": "cost_events",
+ "tableTo": "goals",
+ "columnsFrom": [
+ "goal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": {
+ "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "cost_events",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "heartbeat_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decision_archive_notification_outbox": {
+ "name": "decision_archive_notification_outbox",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "archive_version": {
+ "name": "archive_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_agent_id": {
+ "name": "origin_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_issue_id": {
+ "name": "origin_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "delivered_at": {
+ "name": "delivered_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "decision_archive_notification_outbox_uq": {
+ "name": "decision_archive_notification_outbox_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "archive_version",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_archive_notification_outbox_pending_idx": {
+ "name": "decision_archive_notification_outbox_pending_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_archive_notification_outbox_company_id_companies_id_fk": {
+ "name": "decision_archive_notification_outbox_company_id_companies_id_fk",
+ "tableFrom": "decision_archive_notification_outbox",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_archive_notification_outbox_origin_agent_id_agents_id_fk": {
+ "name": "decision_archive_notification_outbox_origin_agent_id_agents_id_fk",
+ "tableFrom": "decision_archive_notification_outbox",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "origin_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "decision_archive_notification_outbox_status_check": {
+ "name": "decision_archive_notification_outbox_status_check",
+ "value": "\"decision_archive_notification_outbox\".\"status\" IN ('pending', 'delivering', 'delivered')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.decision_queue_items": {
+ "name": "decision_queue_items",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by_type": {
+ "name": "added_by_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "added_by_agent_id": {
+ "name": "added_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by_user_id": {
+ "name": "added_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by_run_id": {
+ "name": "added_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by_agent_api_key_id": {
+ "name": "added_by_agent_api_key_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "decision_queue_items_queue_source_uq": {
+ "name": "decision_queue_items_queue_source_uq",
+ "columns": [
+ {
+ "expression": "queue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_queue_items_company_source_idx": {
+ "name": "decision_queue_items_company_source_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_queue_items_company_id_companies_id_fk": {
+ "name": "decision_queue_items_company_id_companies_id_fk",
+ "tableFrom": "decision_queue_items",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_queue_items_added_by_agent_id_agents_id_fk": {
+ "name": "decision_queue_items_added_by_agent_id_agents_id_fk",
+ "tableFrom": "decision_queue_items",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "added_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk": {
+ "name": "decision_queue_items_added_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "decision_queue_items",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "added_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk": {
+ "name": "decision_queue_items_added_by_agent_api_key_id_agent_api_keys_id_fk",
+ "tableFrom": "decision_queue_items",
+ "tableTo": "agent_api_keys",
+ "columnsFrom": [
+ "added_by_agent_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_queue_items_queue_company_fk": {
+ "name": "decision_queue_items_queue_company_fk",
+ "tableFrom": "decision_queue_items",
+ "tableTo": "decision_queues",
+ "columnsFrom": [
+ "queue_id",
+ "company_id"
+ ],
+ "columnsTo": [
+ "id",
+ "company_id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "decision_queue_items_actor_check": {
+ "name": "decision_queue_items_actor_check",
+ "value": "(\n (\"decision_queue_items\".\"added_by_type\" = 'agent' AND \"decision_queue_items\".\"added_by_agent_id\" IS NOT NULL AND \"decision_queue_items\".\"added_by_user_id\" IS NULL)\n OR (\"decision_queue_items\".\"added_by_type\" = 'user' AND \"decision_queue_items\".\"added_by_agent_id\" IS NULL AND \"decision_queue_items\".\"added_by_user_id\" IS NOT NULL)\n OR (\"decision_queue_items\".\"added_by_type\" = 'system' AND \"decision_queue_items\".\"added_by_agent_id\" IS NULL AND \"decision_queue_items\".\"added_by_user_id\" IS NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.decision_queues": {
+ "name": "decision_queues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_type": {
+ "name": "created_by_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_api_key_id": {
+ "name": "created_by_agent_api_key_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retention_days": {
+ "name": "retention_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seed_rules": {
+ "name": "seed_rules",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "seed_rules_enabled": {
+ "name": "seed_rules_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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": {
+ "decision_queues_company_key_uq": {
+ "name": "decision_queues_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_queues_company_updated_idx": {
+ "name": "decision_queues_company_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_queues_company_id_companies_id_fk": {
+ "name": "decision_queues_company_id_companies_id_fk",
+ "tableFrom": "decision_queues",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_queues_created_by_agent_id_agents_id_fk": {
+ "name": "decision_queues_created_by_agent_id_agents_id_fk",
+ "tableFrom": "decision_queues",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_queues_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "decision_queues_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "decision_queues",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk": {
+ "name": "decision_queues_created_by_agent_api_key_id_agent_api_keys_id_fk",
+ "tableFrom": "decision_queues",
+ "tableTo": "agent_api_keys",
+ "columnsFrom": [
+ "created_by_agent_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "decision_queues_id_company_uq": {
+ "name": "decision_queues_id_company_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "id",
+ "company_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "decision_queues_creator_check": {
+ "name": "decision_queues_creator_check",
+ "value": "(\n (\"decision_queues\".\"created_by_type\" = 'agent' AND \"decision_queues\".\"created_by_agent_id\" IS NOT NULL AND \"decision_queues\".\"created_by_user_id\" IS NULL)\n OR (\"decision_queues\".\"created_by_type\" = 'user' AND \"decision_queues\".\"created_by_agent_id\" IS NULL AND \"decision_queues\".\"created_by_user_id\" IS NOT NULL)\n OR (\"decision_queues\".\"created_by_type\" = 'system' AND \"decision_queues\".\"created_by_agent_id\" IS NULL AND \"decision_queues\".\"created_by_user_id\" IS NULL)\n )"
+ },
+ "decision_queues_retention_days_check": {
+ "name": "decision_queues_retention_days_check",
+ "value": "\"decision_queues\".\"retention_days\" IS NULL OR (\"decision_queues\".\"retention_days\" >= 1 AND \"decision_queues\".\"retention_days\" <= 3650)"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.decision_retention": {
+ "name": "decision_retention",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_activity_at": {
+ "name": "source_activity_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "keep": {
+ "name": "keep",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_reason": {
+ "name": "archived_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_by_type": {
+ "name": "archived_by_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_by_agent_id": {
+ "name": "archived_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_by_user_id": {
+ "name": "archived_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_by_run_id": {
+ "name": "archived_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "archive_version": {
+ "name": "archive_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "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": {
+ "decision_retention_company_source_uq": {
+ "name": "decision_retention_company_source_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_retention_company_archived_idx": {
+ "name": "decision_retention_company_archived_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "archived_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_retention_company_id_companies_id_fk": {
+ "name": "decision_retention_company_id_companies_id_fk",
+ "tableFrom": "decision_retention",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_retention_archived_by_agent_id_agents_id_fk": {
+ "name": "decision_retention_archived_by_agent_id_agents_id_fk",
+ "tableFrom": "decision_retention",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "archived_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_retention_archived_by_run_id_heartbeat_runs_id_fk": {
+ "name": "decision_retention_archived_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "decision_retention",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "archived_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "decision_retention_archive_actor_check": {
+ "name": "decision_retention_archive_actor_check",
+ "value": "(\n (\"decision_retention\".\"archived_at\" IS NULL AND \"decision_retention\".\"archived_by_type\" IS NULL AND \"decision_retention\".\"archived_by_agent_id\" IS NULL AND \"decision_retention\".\"archived_by_user_id\" IS NULL)\n OR (\"decision_retention\".\"archived_at\" IS NOT NULL AND \"decision_retention\".\"archived_by_type\" = 'system' AND \"decision_retention\".\"archived_by_agent_id\" IS NULL AND \"decision_retention\".\"archived_by_user_id\" IS NULL)\n OR (\"decision_retention\".\"archived_at\" IS NOT NULL AND \"decision_retention\".\"archived_by_type\" = 'agent' AND \"decision_retention\".\"archived_by_agent_id\" IS NOT NULL AND \"decision_retention\".\"archived_by_user_id\" IS NULL)\n OR (\"decision_retention\".\"archived_at\" IS NOT NULL AND \"decision_retention\".\"archived_by_type\" = 'user' AND \"decision_retention\".\"archived_by_agent_id\" IS NULL AND \"decision_retention\".\"archived_by_user_id\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.decision_triage": {
+ "name": "decision_triage",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "decide_by": {
+ "name": "decide_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decide_by_date": {
+ "name": "decide_by_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "snoozed_until": {
+ "name": "snoozed_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "set_by_type": {
+ "name": "set_by_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "set_by_agent_id": {
+ "name": "set_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "set_by_user_id": {
+ "name": "set_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "set_by_run_id": {
+ "name": "set_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "set_by_agent_api_key_id": {
+ "name": "set_by_agent_api_key_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "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": {
+ "decision_triage_company_source_uq": {
+ "name": "decision_triage_company_source_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_triage_company_decide_by_idx": {
+ "name": "decision_triage_company_decide_by_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "decide_by",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_triage_company_id_companies_id_fk": {
+ "name": "decision_triage_company_id_companies_id_fk",
+ "tableFrom": "decision_triage",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_triage_set_by_agent_id_agents_id_fk": {
+ "name": "decision_triage_set_by_agent_id_agents_id_fk",
+ "tableFrom": "decision_triage",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "set_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_triage_set_by_run_id_heartbeat_runs_id_fk": {
+ "name": "decision_triage_set_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "decision_triage",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "set_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk": {
+ "name": "decision_triage_set_by_agent_api_key_id_agent_api_keys_id_fk",
+ "tableFrom": "decision_triage",
+ "tableTo": "agent_api_keys",
+ "columnsFrom": [
+ "set_by_agent_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "decision_triage_actor_check": {
+ "name": "decision_triage_actor_check",
+ "value": "(\n (\"decision_triage\".\"set_by_type\" = 'agent' AND \"decision_triage\".\"set_by_agent_id\" IS NOT NULL AND \"decision_triage\".\"set_by_user_id\" IS NULL)\n OR (\"decision_triage\".\"set_by_type\" = 'user' AND \"decision_triage\".\"set_by_agent_id\" IS NULL AND \"decision_triage\".\"set_by_user_id\" IS NOT NULL)\n )"
+ },
+ "decision_triage_decide_by_check": {
+ "name": "decision_triage_decide_by_check",
+ "value": "(\n (\"decision_triage\".\"decide_by\" IS NULL AND \"decision_triage\".\"decide_by_date\" IS NULL)\n OR (\"decision_triage\".\"decide_by\" IN ('today', 'this_week', 'whenever') AND \"decision_triage\".\"decide_by_date\" IS NULL)\n OR (\"decision_triage\".\"decide_by\" = 'date' AND \"decision_triage\".\"decide_by_date\" IS NOT NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.decision_triage_events": {
+ "name": "decision_triage_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_agent_id": {
+ "name": "actor_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_run_id": {
+ "name": "actor_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_api_key_id": {
+ "name": "agent_api_key_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "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": {
+ "decision_triage_events_company_source_created_idx": {
+ "name": "decision_triage_events_company_source_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_triage_events_queue_created_idx": {
+ "name": "decision_triage_events_queue_created_idx",
+ "columns": [
+ {
+ "expression": "queue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_triage_events_company_id_companies_id_fk": {
+ "name": "decision_triage_events_company_id_companies_id_fk",
+ "tableFrom": "decision_triage_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_triage_events_queue_id_decision_queues_id_fk": {
+ "name": "decision_triage_events_queue_id_decision_queues_id_fk",
+ "tableFrom": "decision_triage_events",
+ "tableTo": "decision_queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_triage_events_actor_agent_id_agents_id_fk": {
+ "name": "decision_triage_events_actor_agent_id_agents_id_fk",
+ "tableFrom": "decision_triage_events",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "actor_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_triage_events_actor_run_id_heartbeat_runs_id_fk": {
+ "name": "decision_triage_events_actor_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "decision_triage_events",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "actor_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk": {
+ "name": "decision_triage_events_agent_api_key_id_agent_api_keys_id_fk",
+ "tableFrom": "decision_triage_events",
+ "tableTo": "agent_api_keys",
+ "columnsFrom": [
+ "agent_api_key_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "decision_triage_events_actor_check": {
+ "name": "decision_triage_events_actor_check",
+ "value": "(\n (\"decision_triage_events\".\"actor_type\" = 'agent' AND \"decision_triage_events\".\"actor_agent_id\" IS NOT NULL AND \"decision_triage_events\".\"actor_user_id\" IS NULL)\n OR (\"decision_triage_events\".\"actor_type\" = 'user' AND \"decision_triage_events\".\"actor_agent_id\" IS NULL AND \"decision_triage_events\".\"actor_user_id\" IS NOT NULL)\n OR (\"decision_triage_events\".\"actor_type\" = 'system' AND \"decision_triage_events\".\"actor_agent_id\" IS NULL AND \"decision_triage_events\".\"actor_user_id\" IS NULL)\n )"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.decision_training_examples": {
+ "name": "decision_training_examples",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cutoff_at": {
+ "name": "cutoff_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "notes_history": {
+ "name": "notes_history",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "decision_outcome": {
+ "name": "decision_outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retention_policy": {
+ "name": "retention_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'scrub_deleted_comments_v1'"
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "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": {
+ "decision_training_examples_company_created_at_idx": {
+ "name": "decision_training_examples_company_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_training_examples_issue_idx": {
+ "name": "decision_training_examples_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_training_examples_source_author_uq": {
+ "name": "decision_training_examples_source_author_uq",
+ "columns": [
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_by_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_training_examples_company_id_companies_id_fk": {
+ "name": "decision_training_examples_company_id_companies_id_fk",
+ "tableFrom": "decision_training_examples",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_training_examples_issue_id_issues_id_fk": {
+ "name": "decision_training_examples_issue_id_issues_id_fk",
+ "tableFrom": "decision_training_examples",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decision_bundles": {
+ "name": "decision_bundles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_agent_id": {
+ "name": "origin_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_issue_id": {
+ "name": "origin_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_run_id": {
+ "name": "origin_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "decision_bundles_company_created_at_idx": {
+ "name": "decision_bundles_company_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_bundles_company_id_companies_id_fk": {
+ "name": "decision_bundles_company_id_companies_id_fk",
+ "tableFrom": "decision_bundles",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_bundles_origin_agent_id_agents_id_fk": {
+ "name": "decision_bundles_origin_agent_id_agents_id_fk",
+ "tableFrom": "decision_bundles",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "origin_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_bundles_origin_issue_id_issues_id_fk": {
+ "name": "decision_bundles_origin_issue_id_issues_id_fk",
+ "tableFrom": "decision_bundles",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "origin_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_bundles_origin_run_id_heartbeat_runs_id_fk": {
+ "name": "decision_bundles_origin_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "decision_bundles",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "origin_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decision_effect_executions": {
+ "name": "decision_effect_executions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "decision_id": {
+ "name": "decision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect_index": {
+ "name": "effect_index",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect_type": {
+ "name": "effect_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_issue_id": {
+ "name": "target_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'claimed'"
+ },
+ "result": {
+ "name": "result",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "activity_log_id": {
+ "name": "activity_log_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "executed_at": {
+ "name": "executed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "decision_effect_executions_decision_effect_uq": {
+ "name": "decision_effect_executions_decision_effect_uq",
+ "columns": [
+ {
+ "expression": "decision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "effect_index",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_effect_executions_target_issue_idx": {
+ "name": "decision_effect_executions_target_issue_idx",
+ "columns": [
+ {
+ "expression": "target_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_effect_executions_decision_id_decisions_id_fk": {
+ "name": "decision_effect_executions_decision_id_decisions_id_fk",
+ "tableFrom": "decision_effect_executions",
+ "tableTo": "decisions",
+ "columnsFrom": [
+ "decision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_effect_executions_target_issue_id_issues_id_fk": {
+ "name": "decision_effect_executions_target_issue_id_issues_id_fk",
+ "tableFrom": "decision_effect_executions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "target_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decision_effect_executions_activity_log_id_activity_log_id_fk": {
+ "name": "decision_effect_executions_activity_log_id_activity_log_id_fk",
+ "tableFrom": "decision_effect_executions",
+ "tableTo": "activity_log",
+ "columnsFrom": [
+ "activity_log_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decision_target_issues": {
+ "name": "decision_target_issues",
+ "schema": "",
+ "columns": {
+ "decision_id": {
+ "name": "decision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "decision_target_issues_decision_idx": {
+ "name": "decision_target_issues_decision_idx",
+ "columns": [
+ {
+ "expression": "decision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decision_target_issues_issue_idx": {
+ "name": "decision_target_issues_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decision_target_issues_decision_id_decisions_id_fk": {
+ "name": "decision_target_issues_decision_id_decisions_id_fk",
+ "tableFrom": "decision_target_issues",
+ "tableTo": "decisions",
+ "columnsFrom": [
+ "decision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_target_issues_issue_id_issues_id_fk": {
+ "name": "decision_target_issues_issue_id_issues_id_fk",
+ "tableFrom": "decision_target_issues",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "decision_target_issues_company_id_companies_id_fk": {
+ "name": "decision_target_issues_company_id_companies_id_fk",
+ "tableFrom": "decision_target_issues",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "decision_target_issues_decision_id_issue_id_pk": {
+ "name": "decision_target_issues_decision_id_issue_id_pk",
+ "columns": [
+ "decision_id",
+ "issue_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.decisions": {
+ "name": "decisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bundle_id": {
+ "name": "bundle_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_agent_id": {
+ "name": "origin_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_issue_id": {
+ "name": "origin_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin_run_id": {
+ "name": "origin_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "rule_key": {
+ "name": "rule_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "inputs": {
+ "name": "inputs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "execution_status": {
+ "name": "execution_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "chosen_option_id": {
+ "name": "chosen_option_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_values": {
+ "name": "input_values",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_user_id": {
+ "name": "decided_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signed_spec": {
+ "name": "signed_spec",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_snapshots": {
+ "name": "target_snapshots",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "continuation_policy": {
+ "name": "continuation_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "decisions_company_status_expires_at_idx": {
+ "name": "decisions_company_status_expires_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decisions_bundle_idx": {
+ "name": "decisions_bundle_idx",
+ "columns": [
+ {
+ "expression": "bundle_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decisions_origin_issue_idx": {
+ "name": "decisions_origin_issue_idx",
+ "columns": [
+ {
+ "expression": "origin_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "decisions_company_idempotency_uq": {
+ "name": "decisions_company_idempotency_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"decisions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "decisions_company_id_companies_id_fk": {
+ "name": "decisions_company_id_companies_id_fk",
+ "tableFrom": "decisions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decisions_bundle_id_decision_bundles_id_fk": {
+ "name": "decisions_bundle_id_decision_bundles_id_fk",
+ "tableFrom": "decisions",
+ "tableTo": "decision_bundles",
+ "columnsFrom": [
+ "bundle_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "decisions_origin_agent_id_agents_id_fk": {
+ "name": "decisions_origin_agent_id_agents_id_fk",
+ "tableFrom": "decisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "origin_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decisions_origin_issue_id_issues_id_fk": {
+ "name": "decisions_origin_issue_id_issues_id_fk",
+ "tableFrom": "decisions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "origin_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "decisions_origin_run_id_heartbeat_runs_id_fk": {
+ "name": "decisions_origin_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "decisions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "origin_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_annotation_anchor_snapshots": {
+ "name": "document_annotation_anchor_snapshots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_revision_id": {
+ "name": "from_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_revision_number": {
+ "name": "from_revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_revision_id": {
+ "name": "to_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_revision_number": {
+ "name": "to_revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "previous_anchor": {
+ "name": "previous_anchor",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "next_anchor": {
+ "name": "next_anchor",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "anchor_state": {
+ "name": "anchor_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "anchor_confidence": {
+ "name": "anchor_confidence",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_annotation_anchor_snapshots_company_thread_created_at_idx": {
+ "name": "document_annotation_anchor_snapshots_company_thread_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_anchor_snapshots_company_document_revision_idx": {
+ "name": "document_annotation_anchor_snapshots_company_document_revision_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "to_revision_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_annotation_anchor_snapshots_company_id_companies_id_fk": {
+ "name": "document_annotation_anchor_snapshots_company_id_companies_id_fk",
+ "tableFrom": "document_annotation_anchor_snapshots",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk": {
+ "name": "document_annotation_anchor_snapshots_thread_id_document_annotation_threads_id_fk",
+ "tableFrom": "document_annotation_anchor_snapshots",
+ "tableTo": "document_annotation_threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_anchor_snapshots_document_id_documents_id_fk": {
+ "name": "document_annotation_anchor_snapshots_document_id_documents_id_fk",
+ "tableFrom": "document_annotation_anchor_snapshots",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk": {
+ "name": "document_annotation_anchor_snapshots_from_revision_id_document_revisions_id_fk",
+ "tableFrom": "document_annotation_anchor_snapshots",
+ "tableTo": "document_revisions",
+ "columnsFrom": [
+ "from_revision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk": {
+ "name": "document_annotation_anchor_snapshots_to_revision_id_document_revisions_id_fk",
+ "tableFrom": "document_annotation_anchor_snapshots",
+ "tableTo": "document_revisions",
+ "columnsFrom": [
+ "to_revision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_annotation_comments": {
+ "name": "document_annotation_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_type": {
+ "name": "author_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_agent_id": {
+ "name": "author_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_user_id": {
+ "name": "author_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_comment_id": {
+ "name": "issue_comment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_trust": {
+ "name": "source_trust",
+ "type": "jsonb",
+ "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": {
+ "document_annotation_comments_company_thread_created_at_idx": {
+ "name": "document_annotation_comments_company_thread_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_comments_company_issue_created_at_idx": {
+ "name": "document_annotation_comments_company_issue_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_comments_company_routine_created_at_idx": {
+ "name": "document_annotation_comments_company_routine_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_comments_company_case_created_at_idx": {
+ "name": "document_annotation_comments_company_case_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_comments_company_document_created_at_idx": {
+ "name": "document_annotation_comments_company_document_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_comments_issue_comment_idx": {
+ "name": "document_annotation_comments_issue_comment_idx",
+ "columns": [
+ {
+ "expression": "issue_comment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_comments_body_search_idx": {
+ "name": "document_annotation_comments_body_search_idx",
+ "columns": [
+ {
+ "expression": "body",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_annotation_comments_company_id_companies_id_fk": {
+ "name": "document_annotation_comments_company_id_companies_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_thread_id_document_annotation_threads_id_fk": {
+ "name": "document_annotation_comments_thread_id_document_annotation_threads_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "document_annotation_threads",
+ "columnsFrom": [
+ "thread_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_issue_id_issues_id_fk": {
+ "name": "document_annotation_comments_issue_id_issues_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_routine_id_routines_id_fk": {
+ "name": "document_annotation_comments_routine_id_routines_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_case_id_cases_id_fk": {
+ "name": "document_annotation_comments_case_id_cases_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_document_id_documents_id_fk": {
+ "name": "document_annotation_comments_document_id_documents_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_author_agent_id_agents_id_fk": {
+ "name": "document_annotation_comments_author_agent_id_agents_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "author_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "document_annotation_comments_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "document_annotation_comments_issue_comment_id_issue_comments_id_fk": {
+ "name": "document_annotation_comments_issue_comment_id_issue_comments_id_fk",
+ "tableFrom": "document_annotation_comments",
+ "tableTo": "issue_comments",
+ "columnsFrom": [
+ "issue_comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "document_annotation_comments_exactly_one_owner_chk": {
+ "name": "document_annotation_comments_exactly_one_owner_chk",
+ "value": "num_nonnulls(\"document_annotation_comments\".\"issue_id\", \"document_annotation_comments\".\"routine_id\", \"document_annotation_comments\".\"case_id\") = 1"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.document_annotation_threads": {
+ "name": "document_annotation_threads",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "anchor_state": {
+ "name": "anchor_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "original_revision_id": {
+ "name": "original_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_revision_number": {
+ "name": "original_revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "current_revision_id": {
+ "name": "current_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "current_revision_number": {
+ "name": "current_revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "selected_text": {
+ "name": "selected_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "prefix_text": {
+ "name": "prefix_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "suffix_text": {
+ "name": "suffix_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "normalized_start": {
+ "name": "normalized_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "normalized_end": {
+ "name": "normalized_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "markdown_start": {
+ "name": "markdown_start",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "markdown_end": {
+ "name": "markdown_end",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "anchor_confidence": {
+ "name": "anchor_confidence",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'exact'"
+ },
+ "anchor_selector": {
+ "name": "anchor_selector",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_agent_id": {
+ "name": "resolved_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_user_id": {
+ "name": "resolved_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_annotation_threads_company_document_status_idx": {
+ "name": "document_annotation_threads_company_document_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_threads_company_issue_status_idx": {
+ "name": "document_annotation_threads_company_issue_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_threads_company_routine_status_idx": {
+ "name": "document_annotation_threads_company_routine_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_threads_company_case_status_idx": {
+ "name": "document_annotation_threads_company_case_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_threads_company_current_revision_open_idx": {
+ "name": "document_annotation_threads_company_current_revision_open_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "current_revision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_annotation_threads_company_anchor_state_idx": {
+ "name": "document_annotation_threads_company_anchor_state_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "anchor_state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_annotation_threads_company_id_companies_id_fk": {
+ "name": "document_annotation_threads_company_id_companies_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_issue_id_issues_id_fk": {
+ "name": "document_annotation_threads_issue_id_issues_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_routine_id_routines_id_fk": {
+ "name": "document_annotation_threads_routine_id_routines_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_case_id_cases_id_fk": {
+ "name": "document_annotation_threads_case_id_cases_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_document_id_documents_id_fk": {
+ "name": "document_annotation_threads_document_id_documents_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_original_revision_id_document_revisions_id_fk": {
+ "name": "document_annotation_threads_original_revision_id_document_revisions_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "document_revisions",
+ "columnsFrom": [
+ "original_revision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_current_revision_id_document_revisions_id_fk": {
+ "name": "document_annotation_threads_current_revision_id_document_revisions_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "document_revisions",
+ "columnsFrom": [
+ "current_revision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_created_by_agent_id_agents_id_fk": {
+ "name": "document_annotation_threads_created_by_agent_id_agents_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "document_annotation_threads_resolved_by_agent_id_agents_id_fk": {
+ "name": "document_annotation_threads_resolved_by_agent_id_agents_id_fk",
+ "tableFrom": "document_annotation_threads",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "resolved_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "document_annotation_threads_exactly_one_owner_chk": {
+ "name": "document_annotation_threads_exactly_one_owner_chk",
+ "value": "num_nonnulls(\"document_annotation_threads\".\"issue_id\", \"document_annotation_threads\".\"routine_id\", \"document_annotation_threads\".\"case_id\") = 1"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.document_memberships": {
+ "name": "document_memberships",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "starred_at": {
+ "name": "starred_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_memberships_company_user_starred_idx": {
+ "name": "document_memberships_company_user_starred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "starred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_memberships_company_user_document_uq": {
+ "name": "document_memberships_company_user_document_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_memberships_company_id_companies_id_fk": {
+ "name": "document_memberships_company_id_companies_id_fk",
+ "tableFrom": "document_memberships",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_memberships_document_id_documents_id_fk": {
+ "name": "document_memberships_document_id_documents_id_fk",
+ "tableFrom": "document_memberships",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.document_revisions": {
+ "name": "document_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revision_number": {
+ "name": "revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'markdown'"
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "change_summary": {
+ "name": "change_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "document_revisions_document_revision_uq": {
+ "name": "document_revisions_document_revision_uq",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "revision_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "document_revisions_company_document_created_idx": {
+ "name": "document_revisions_company_document_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "document_revisions_company_id_companies_id_fk": {
+ "name": "document_revisions_company_id_companies_id_fk",
+ "tableFrom": "document_revisions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_revisions_document_id_documents_id_fk": {
+ "name": "document_revisions_document_id_documents_id_fk",
+ "tableFrom": "document_revisions",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "document_revisions_created_by_agent_id_agents_id_fk": {
+ "name": "document_revisions_created_by_agent_id_agents_id_fk",
+ "tableFrom": "document_revisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "document_revisions_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "document_revisions_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "document_revisions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.documents": {
+ "name": "documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "format": {
+ "name": "format",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'markdown'"
+ },
+ "latest_body": {
+ "name": "latest_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "latest_revision_id": {
+ "name": "latest_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latest_revision_number": {
+ "name": "latest_revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_agent_id": {
+ "name": "updated_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locked_at": {
+ "name": "locked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locked_by_agent_id": {
+ "name": "locked_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "locked_by_user_id": {
+ "name": "locked_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_trust": {
+ "name": "source_trust",
+ "type": "jsonb",
+ "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": {
+ "documents_company_updated_idx": {
+ "name": "documents_company_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "documents_company_created_idx": {
+ "name": "documents_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "documents_title_search_idx": {
+ "name": "documents_title_search_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "documents_latest_body_search_idx": {
+ "name": "documents_latest_body_search_idx",
+ "columns": [
+ {
+ "expression": "latest_body",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "documents_company_id_companies_id_fk": {
+ "name": "documents_company_id_companies_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "documents_created_by_agent_id_agents_id_fk": {
+ "name": "documents_created_by_agent_id_agents_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "documents_updated_by_agent_id_agents_id_fk": {
+ "name": "documents_updated_by_agent_id_agents_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "updated_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "documents_locked_by_agent_id_agents_id_fk": {
+ "name": "documents_locked_by_agent_id_agents_id_fk",
+ "tableFrom": "documents",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "locked_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.environment_custom_image_setup_sessions": {
+ "name": "environment_custom_image_setup_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template_id": {
+ "name": "template_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "promoted_template_id": {
+ "name": "promoted_template_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_lease_id": {
+ "name": "provider_lease_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "environment_lease_id": {
+ "name": "environment_lease_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'starting'"
+ },
+ "started_by_user_id": {
+ "name": "started_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_by_agent_id": {
+ "name": "started_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_template_ref": {
+ "name": "base_template_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_summary": {
+ "name": "connection_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_secret_ref": {
+ "name": "connection_secret_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "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": {
+ "environment_custom_image_setup_sessions_environment_status_idx": {
+ "name": "environment_custom_image_setup_sessions_environment_status_idx",
+ "columns": [
+ {
+ "expression": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_setup_sessions_environment_active_uq": {
+ "name": "environment_custom_image_setup_sessions_environment_active_uq",
+ "columns": [
+ {
+ "expression": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"environment_custom_image_setup_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'capturing')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_setup_sessions_template_idx": {
+ "name": "environment_custom_image_setup_sessions_template_idx",
+ "columns": [
+ {
+ "expression": "template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_setup_sessions_promoted_template_idx": {
+ "name": "environment_custom_image_setup_sessions_promoted_template_idx",
+ "columns": [
+ {
+ "expression": "promoted_template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_setup_sessions_expires_idx": {
+ "name": "environment_custom_image_setup_sessions_expires_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_setup_sessions_provider_lease_idx": {
+ "name": "environment_custom_image_setup_sessions_provider_lease_idx",
+ "columns": [
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_lease_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "environment_custom_image_setup_sessions_environment_id_environments_id_fk": {
+ "name": "environment_custom_image_setup_sessions_environment_id_environments_id_fk",
+ "tableFrom": "environment_custom_image_setup_sessions",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk": {
+ "name": "environment_custom_image_setup_sessions_template_id_environment_custom_image_templates_id_fk",
+ "tableFrom": "environment_custom_image_setup_sessions",
+ "tableTo": "environment_custom_image_templates",
+ "columnsFrom": [
+ "template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk": {
+ "name": "environment_custom_image_setup_sessions_promoted_template_id_environment_custom_image_templates_id_fk",
+ "tableFrom": "environment_custom_image_setup_sessions",
+ "tableTo": "environment_custom_image_templates",
+ "columnsFrom": [
+ "promoted_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk": {
+ "name": "environment_custom_image_setup_sessions_environment_lease_id_environment_leases_id_fk",
+ "tableFrom": "environment_custom_image_setup_sessions",
+ "tableTo": "environment_leases",
+ "columnsFrom": [
+ "environment_lease_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk": {
+ "name": "environment_custom_image_setup_sessions_started_by_agent_id_agents_id_fk",
+ "tableFrom": "environment_custom_image_setup_sessions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "started_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.environment_custom_image_templates": {
+ "name": "environment_custom_image_templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template_kind": {
+ "name": "template_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "template_ref": {
+ "name": "template_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_template_ref": {
+ "name": "source_template_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_environment_config_fingerprint": {
+ "name": "source_environment_config_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "captured_at": {
+ "name": "captured_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
+ },
+ "superseded_by_template_id": {
+ "name": "superseded_by_template_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "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": {
+ "environment_custom_image_templates_environment_status_idx": {
+ "name": "environment_custom_image_templates_environment_status_idx",
+ "columns": [
+ {
+ "expression": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_templates_environment_provider_status_idx": {
+ "name": "environment_custom_image_templates_environment_provider_status_idx",
+ "columns": [
+ {
+ "expression": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_templates_environment_active_uq": {
+ "name": "environment_custom_image_templates_environment_active_uq",
+ "columns": [
+ {
+ "expression": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"environment_custom_image_templates\".\"status\" = 'active'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_templates_superseded_by_idx": {
+ "name": "environment_custom_image_templates_superseded_by_idx",
+ "columns": [
+ {
+ "expression": "superseded_by_template_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_custom_image_templates_last_used_idx": {
+ "name": "environment_custom_image_templates_last_used_idx",
+ "columns": [
+ {
+ "expression": "last_used_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "environment_custom_image_templates_environment_id_environments_id_fk": {
+ "name": "environment_custom_image_templates_environment_id_environments_id_fk",
+ "tableFrom": "environment_custom_image_templates",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "environment_custom_image_templates_created_by_agent_id_agents_id_fk": {
+ "name": "environment_custom_image_templates_created_by_agent_id_agents_id_fk",
+ "tableFrom": "environment_custom_image_templates",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk": {
+ "name": "environment_custom_image_templates_superseded_by_template_id_environment_custom_image_templates_id_fk",
+ "tableFrom": "environment_custom_image_templates",
+ "tableTo": "environment_custom_image_templates",
+ "columnsFrom": [
+ "superseded_by_template_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.environment_leases": {
+ "name": "environment_leases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "environment_id": {
+ "name": "environment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_workspace_id": {
+ "name": "execution_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heartbeat_run_id": {
+ "name": "heartbeat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "lease_policy": {
+ "name": "lease_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ephemeral'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_lease_id": {
+ "name": "provider_lease_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "acquired_at": {
+ "name": "acquired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_used_at": {
+ "name": "last_used_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
+ },
+ "released_at": {
+ "name": "released_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cleanup_status": {
+ "name": "cleanup_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "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": {
+ "environment_leases_company_environment_status_idx": {
+ "name": "environment_leases_company_environment_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "environment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_leases_company_execution_workspace_idx": {
+ "name": "environment_leases_company_execution_workspace_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "execution_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_leases_company_issue_idx": {
+ "name": "environment_leases_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_leases_heartbeat_run_idx": {
+ "name": "environment_leases_heartbeat_run_idx",
+ "columns": [
+ {
+ "expression": "heartbeat_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_leases_company_last_used_idx": {
+ "name": "environment_leases_company_last_used_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_used_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environment_leases_provider_lease_idx": {
+ "name": "environment_leases_provider_lease_idx",
+ "columns": [
+ {
+ "expression": "provider_lease_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "environment_leases_company_id_companies_id_fk": {
+ "name": "environment_leases_company_id_companies_id_fk",
+ "tableFrom": "environment_leases",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "environment_leases_environment_id_environments_id_fk": {
+ "name": "environment_leases_environment_id_environments_id_fk",
+ "tableFrom": "environment_leases",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "environment_leases_execution_workspace_id_execution_workspaces_id_fk": {
+ "name": "environment_leases_execution_workspace_id_execution_workspaces_id_fk",
+ "tableFrom": "environment_leases",
+ "tableTo": "execution_workspaces",
+ "columnsFrom": [
+ "execution_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "environment_leases_issue_id_issues_id_fk": {
+ "name": "environment_leases_issue_id_issues_id_fk",
+ "tableFrom": "environment_leases",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk": {
+ "name": "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "environment_leases",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "heartbeat_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.environments": {
+ "name": "environments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "driver": {
+ "name": "driver",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "env_vars": {
+ "name": "env_vars",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "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": {
+ "environments_status_idx": {
+ "name": "environments_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environments_local_driver_idx": {
+ "name": "environments_local_driver_idx",
+ "columns": [
+ {
+ "expression": "driver",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"environments\".\"driver\" = 'local'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environments_managed_sandbox_idx": {
+ "name": "environments_managed_sandbox_idx",
+ "columns": [
+ {
+ "expression": "driver",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"environments\".\"driver\" = 'sandbox' AND (\"environments\".\"metadata\" ->> 'managedByPaperclip')::boolean = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "environments_name_idx": {
+ "name": "environments_name_idx",
+ "columns": [
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.execution_workspaces": {
+ "name": "execution_workspaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_workspace_id": {
+ "name": "project_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_issue_id": {
+ "name": "source_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy_type": {
+ "name": "strategy_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "cwd": {
+ "name": "cwd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "repo_url": {
+ "name": "repo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "base_ref": {
+ "name": "base_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "branch_name": {
+ "name": "branch_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_type": {
+ "name": "provider_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local_fs'"
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "derived_from_execution_workspace_id": {
+ "name": "derived_from_execution_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "opened_at": {
+ "name": "opened_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "closed_at": {
+ "name": "closed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cleanup_eligible_at": {
+ "name": "cleanup_eligible_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cleanup_reason": {
+ "name": "cleanup_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "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": {
+ "execution_workspaces_company_project_status_idx": {
+ "name": "execution_workspaces_company_project_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "execution_workspaces_company_project_workspace_status_idx": {
+ "name": "execution_workspaces_company_project_workspace_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "execution_workspaces_company_source_issue_idx": {
+ "name": "execution_workspaces_company_source_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "execution_workspaces_company_last_used_idx": {
+ "name": "execution_workspaces_company_last_used_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_used_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "execution_workspaces_company_branch_idx": {
+ "name": "execution_workspaces_company_branch_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "branch_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "execution_workspaces_company_id_companies_id_fk": {
+ "name": "execution_workspaces_company_id_companies_id_fk",
+ "tableFrom": "execution_workspaces",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "execution_workspaces_project_id_projects_id_fk": {
+ "name": "execution_workspaces_project_id_projects_id_fk",
+ "tableFrom": "execution_workspaces",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "execution_workspaces_project_workspace_id_project_workspaces_id_fk": {
+ "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk",
+ "tableFrom": "execution_workspaces",
+ "tableTo": "project_workspaces",
+ "columnsFrom": [
+ "project_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "execution_workspaces_source_issue_id_issues_id_fk": {
+ "name": "execution_workspaces_source_issue_id_issues_id_fk",
+ "tableFrom": "execution_workspaces",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "source_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": {
+ "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk",
+ "tableFrom": "execution_workspaces",
+ "tableTo": "execution_workspaces",
+ "columnsFrom": [
+ "derived_from_execution_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.external_object_mentions": {
+ "name": "external_object_mentions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_issue_id": {
+ "name": "source_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_record_id": {
+ "name": "source_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "property_key": {
+ "name": "property_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "matched_text_redacted": {
+ "name": "matched_text_redacted",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sanitized_display_url": {
+ "name": "sanitized_display_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "canonical_identity_hash": {
+ "name": "canonical_identity_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "canonical_identity": {
+ "name": "canonical_identity",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "object_id": {
+ "name": "object_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_key": {
+ "name": "provider_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "detector_key": {
+ "name": "detector_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "object_type": {
+ "name": "object_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "confidence": {
+ "name": "confidence",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'exact'"
+ },
+ "created_by_plugin_id": {
+ "name": "created_by_plugin_id",
+ "type": "uuid",
+ "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": {
+ "external_object_mentions_company_source_issue_idx": {
+ "name": "external_object_mentions_company_source_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_object_mentions_company_object_idx": {
+ "name": "external_object_mentions_company_object_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "object_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_object_mentions_company_provider_idx": {
+ "name": "external_object_mentions_company_provider_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "object_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_object_mentions_company_source_record_uq": {
+ "name": "external_object_mentions_company_source_record_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_record_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "property_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "canonical_identity_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"external_object_mentions\".\"source_record_id\" is not null and \"external_object_mentions\".\"canonical_identity_hash\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_object_mentions_company_source_null_record_uq": {
+ "name": "external_object_mentions_company_source_null_record_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "document_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "property_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "canonical_identity_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"external_object_mentions\".\"source_record_id\" is null and \"external_object_mentions\".\"canonical_identity_hash\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "external_object_mentions_company_id_companies_id_fk": {
+ "name": "external_object_mentions_company_id_companies_id_fk",
+ "tableFrom": "external_object_mentions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "external_object_mentions_source_issue_id_issues_id_fk": {
+ "name": "external_object_mentions_source_issue_id_issues_id_fk",
+ "tableFrom": "external_object_mentions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "source_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "external_object_mentions_object_id_external_objects_id_fk": {
+ "name": "external_object_mentions_object_id_external_objects_id_fk",
+ "tableFrom": "external_object_mentions",
+ "tableTo": "external_objects",
+ "columnsFrom": [
+ "object_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "external_object_mentions_created_by_plugin_id_plugins_id_fk": {
+ "name": "external_object_mentions_created_by_plugin_id_plugins_id_fk",
+ "tableFrom": "external_object_mentions",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "created_by_plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.external_objects": {
+ "name": "external_objects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_key": {
+ "name": "provider_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "object_type": {
+ "name": "object_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sanitized_canonical_url": {
+ "name": "sanitized_canonical_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "canonical_identity_hash": {
+ "name": "canonical_identity_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_key": {
+ "name": "display_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_key": {
+ "name": "icon_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_title": {
+ "name": "display_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_key": {
+ "name": "status_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_label": {
+ "name": "status_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_icon_key": {
+ "name": "status_icon_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_category": {
+ "name": "status_category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "status_tone": {
+ "name": "status_tone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'neutral'"
+ },
+ "liveness": {
+ "name": "liveness",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "is_terminal": {
+ "name": "is_terminal",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "remote_version": {
+ "name": "remote_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "etag": {
+ "name": "etag",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_resolved_at": {
+ "name": "last_resolved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_changed_at": {
+ "name": "last_changed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_at": {
+ "name": "last_error_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_refresh_at": {
+ "name": "next_refresh_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_started_at": {
+ "name": "refresh_started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_code": {
+ "name": "last_error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error_message": {
+ "name": "last_error_message",
+ "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": {
+ "external_objects_company_provider_object_idx": {
+ "name": "external_objects_company_provider_object_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "object_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_objects_company_provider_status_idx": {
+ "name": "external_objects_company_provider_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status_category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_objects_company_refresh_idx": {
+ "name": "external_objects_company_refresh_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_refresh_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_objects_company_external_id_uq": {
+ "name": "external_objects_company_external_id_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "object_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "external_objects_company_identity_uq": {
+ "name": "external_objects_company_identity_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "object_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "canonical_identity_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "external_objects_company_id_companies_id_fk": {
+ "name": "external_objects_company_id_companies_id_fk",
+ "tableFrom": "external_objects",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "external_objects_plugin_id_plugins_id_fk": {
+ "name": "external_objects_plugin_id_plugins_id_fk",
+ "tableFrom": "external_objects",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback_exports": {
+ "name": "feedback_exports",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "feedback_vote_id": {
+ "name": "feedback_vote_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_user_id": {
+ "name": "author_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vote": {
+ "name": "vote",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local_only'"
+ },
+ "destination": {
+ "name": "destination",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "export_id": {
+ "name": "export_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consent_version": {
+ "name": "consent_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "schema_version": {
+ "name": "schema_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'paperclip-feedback-envelope-v2'"
+ },
+ "bundle_version": {
+ "name": "bundle_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'paperclip-feedback-bundle-v2'"
+ },
+ "payload_version": {
+ "name": "payload_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'paperclip-feedback-v1'"
+ },
+ "payload_digest": {
+ "name": "payload_digest",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload_snapshot": {
+ "name": "payload_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_summary": {
+ "name": "target_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "redaction_summary": {
+ "name": "redaction_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_attempted_at": {
+ "name": "last_attempted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "exported_at": {
+ "name": "exported_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "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": {
+ "feedback_exports_feedback_vote_idx": {
+ "name": "feedback_exports_feedback_vote_idx",
+ "columns": [
+ {
+ "expression": "feedback_vote_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_exports_company_created_idx": {
+ "name": "feedback_exports_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_exports_company_status_idx": {
+ "name": "feedback_exports_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_exports_company_issue_idx": {
+ "name": "feedback_exports_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_exports_company_project_idx": {
+ "name": "feedback_exports_company_project_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "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": {}
+ },
+ "feedback_exports_company_author_idx": {
+ "name": "feedback_exports_company_author_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "author_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_exports_company_id_companies_id_fk": {
+ "name": "feedback_exports_company_id_companies_id_fk",
+ "tableFrom": "feedback_exports",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "feedback_exports_feedback_vote_id_feedback_votes_id_fk": {
+ "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk",
+ "tableFrom": "feedback_exports",
+ "tableTo": "feedback_votes",
+ "columnsFrom": [
+ "feedback_vote_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "feedback_exports_issue_id_issues_id_fk": {
+ "name": "feedback_exports_issue_id_issues_id_fk",
+ "tableFrom": "feedback_exports",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "feedback_exports_project_id_projects_id_fk": {
+ "name": "feedback_exports_project_id_projects_id_fk",
+ "tableFrom": "feedback_exports",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feedback_votes": {
+ "name": "feedback_votes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_user_id": {
+ "name": "author_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vote": {
+ "name": "vote",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shared_with_labs": {
+ "name": "shared_with_labs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "shared_at": {
+ "name": "shared_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consent_version": {
+ "name": "consent_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redaction_summary": {
+ "name": "redaction_summary",
+ "type": "jsonb",
+ "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": {
+ "feedback_votes_company_issue_idx": {
+ "name": "feedback_votes_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_votes_issue_target_idx": {
+ "name": "feedback_votes_issue_target_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_votes_author_idx": {
+ "name": "feedback_votes_author_idx",
+ "columns": [
+ {
+ "expression": "author_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "feedback_votes_company_target_author_idx": {
+ "name": "feedback_votes_company_target_author_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "author_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "feedback_votes_company_id_companies_id_fk": {
+ "name": "feedback_votes_company_id_companies_id_fk",
+ "tableFrom": "feedback_votes",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "feedback_votes_issue_id_issues_id_fk": {
+ "name": "feedback_votes_issue_id_issues_id_fk",
+ "tableFrom": "feedback_votes",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.finance_events": {
+ "name": "finance_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "goal_id": {
+ "name": "goal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heartbeat_run_id": {
+ "name": "heartbeat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cost_event_id": {
+ "name": "cost_event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billing_code": {
+ "name": "billing_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_kind": {
+ "name": "event_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'debit'"
+ },
+ "biller": {
+ "name": "biller",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_adapter_type": {
+ "name": "execution_adapter_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pricing_tier": {
+ "name": "pricing_tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "region": {
+ "name": "region",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quantity": {
+ "name": "quantity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "unit": {
+ "name": "unit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "amount_cents": {
+ "name": "amount_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "currency": {
+ "name": "currency",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'USD'"
+ },
+ "estimated": {
+ "name": "estimated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "external_invoice_id": {
+ "name": "external_invoice_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata_json": {
+ "name": "metadata_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "occurred_at": {
+ "name": "occurred_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()"
+ }
+ },
+ "indexes": {
+ "finance_events_company_occurred_idx": {
+ "name": "finance_events_company_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "finance_events_company_biller_occurred_idx": {
+ "name": "finance_events_company_biller_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "biller",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "finance_events_company_kind_occurred_idx": {
+ "name": "finance_events_company_kind_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "event_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "finance_events_company_direction_occurred_idx": {
+ "name": "finance_events_company_direction_occurred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "direction",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "occurred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "finance_events_company_heartbeat_run_idx": {
+ "name": "finance_events_company_heartbeat_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "heartbeat_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "finance_events_company_cost_event_idx": {
+ "name": "finance_events_company_cost_event_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "cost_event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "finance_events_company_id_companies_id_fk": {
+ "name": "finance_events_company_id_companies_id_fk",
+ "tableFrom": "finance_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "finance_events_agent_id_agents_id_fk": {
+ "name": "finance_events_agent_id_agents_id_fk",
+ "tableFrom": "finance_events",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "finance_events_issue_id_issues_id_fk": {
+ "name": "finance_events_issue_id_issues_id_fk",
+ "tableFrom": "finance_events",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "finance_events_project_id_projects_id_fk": {
+ "name": "finance_events_project_id_projects_id_fk",
+ "tableFrom": "finance_events",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "finance_events_goal_id_goals_id_fk": {
+ "name": "finance_events_goal_id_goals_id_fk",
+ "tableFrom": "finance_events",
+ "tableTo": "goals",
+ "columnsFrom": [
+ "goal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": {
+ "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "finance_events",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "heartbeat_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "finance_events_cost_event_id_cost_events_id_fk": {
+ "name": "finance_events_cost_event_id_cost_events_id_fk",
+ "tableFrom": "finance_events",
+ "tableTo": "cost_events",
+ "columnsFrom": [
+ "cost_event_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.folders": {
+ "name": "folders",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "system_key": {
+ "name": "system_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "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": {
+ "folders_company_kind_position_idx": {
+ "name": "folders_company_kind_position_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "folders_company_kind_root_slug_uq": {
+ "name": "folders_company_kind_root_slug_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"folders\".\"parent_id\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "folders_company_kind_parent_slug_uq": {
+ "name": "folders_company_kind_parent_slug_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"folders\".\"parent_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "folders_company_kind_system_key_uq": {
+ "name": "folders_company_kind_system_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "system_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"folders\".\"system_key\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "folders_company_kind_parent_position_idx": {
+ "name": "folders_company_kind_parent_position_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "folders_company_id_companies_id_fk": {
+ "name": "folders_company_id_companies_id_fk",
+ "tableFrom": "folders",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "folders_parent_id_folders_id_fk": {
+ "name": "folders_parent_id_folders_id_fk",
+ "tableFrom": "folders",
+ "tableTo": "folders",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.goals": {
+ "name": "goals",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "level": {
+ "name": "level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'task'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'planned'"
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_agent_id": {
+ "name": "owner_agent_id",
+ "type": "uuid",
+ "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": {
+ "goals_company_idx": {
+ "name": "goals_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "goals_company_id_companies_id_fk": {
+ "name": "goals_company_id_companies_id_fk",
+ "tableFrom": "goals",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "goals_parent_id_goals_id_fk": {
+ "name": "goals_parent_id_goals_id_fk",
+ "tableFrom": "goals",
+ "tableTo": "goals",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "goals_owner_agent_id_agents_id_fk": {
+ "name": "goals_owner_agent_id_agents_id_fk",
+ "tableFrom": "goals",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "owner_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.heartbeat_run_events": {
+ "name": "heartbeat_run_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seq": {
+ "name": "seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stream": {
+ "name": "stream",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "level": {
+ "name": "level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "heartbeat_run_events_run_seq_idx": {
+ "name": "heartbeat_run_events_run_seq_idx",
+ "columns": [
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "seq",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_run_events_company_run_idx": {
+ "name": "heartbeat_run_events_company_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_run_events_company_created_idx": {
+ "name": "heartbeat_run_events_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "heartbeat_run_events_company_id_companies_id_fk": {
+ "name": "heartbeat_run_events_company_id_companies_id_fk",
+ "tableFrom": "heartbeat_run_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "heartbeat_run_events_run_id_heartbeat_runs_id_fk": {
+ "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "heartbeat_run_events",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "heartbeat_run_events_agent_id_agents_id_fk": {
+ "name": "heartbeat_run_events_agent_id_agents_id_fk",
+ "tableFrom": "heartbeat_run_events",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.heartbeat_run_watchdog_decisions": {
+ "name": "heartbeat_run_watchdog_decisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "evaluation_issue_id": {
+ "name": "evaluation_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision": {
+ "name": "decision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "snoozed_until": {
+ "name": "snoozed_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "heartbeat_run_watchdog_decisions_company_run_created_idx": {
+ "name": "heartbeat_run_watchdog_decisions_company_run_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "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": {}
+ },
+ "heartbeat_run_watchdog_decisions_company_run_snooze_idx": {
+ "name": "heartbeat_run_watchdog_decisions_company_run_snooze_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "snoozed_until",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "heartbeat_run_watchdog_decisions_company_id_companies_id_fk": {
+ "name": "heartbeat_run_watchdog_decisions_company_id_companies_id_fk",
+ "tableFrom": "heartbeat_run_watchdog_decisions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk": {
+ "name": "heartbeat_run_watchdog_decisions_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "heartbeat_run_watchdog_decisions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk": {
+ "name": "heartbeat_run_watchdog_decisions_evaluation_issue_id_issues_id_fk",
+ "tableFrom": "heartbeat_run_watchdog_decisions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "evaluation_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk": {
+ "name": "heartbeat_run_watchdog_decisions_created_by_agent_id_agents_id_fk",
+ "tableFrom": "heartbeat_run_watchdog_decisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "heartbeat_run_watchdog_decisions_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "heartbeat_run_watchdog_decisions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.heartbeat_runs": {
+ "name": "heartbeat_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invocation_source": {
+ "name": "invocation_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'on_demand'"
+ },
+ "trigger_detail": {
+ "name": "trigger_detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'queued'"
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "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
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wakeup_request_id": {
+ "name": "wakeup_request_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "exit_code": {
+ "name": "exit_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signal": {
+ "name": "signal",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_json": {
+ "name": "usage_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_json": {
+ "name": "result_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_id_before": {
+ "name": "session_id_before",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_id_after": {
+ "name": "session_id_after",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_store": {
+ "name": "log_store",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_ref": {
+ "name": "log_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_bytes": {
+ "name": "log_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_sha256": {
+ "name": "log_sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_compressed": {
+ "name": "log_compressed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "stdout_excerpt": {
+ "name": "stdout_excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stderr_excerpt": {
+ "name": "stderr_excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_run_id": {
+ "name": "external_run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "process_pid": {
+ "name": "process_pid",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "process_group_id": {
+ "name": "process_group_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "process_started_at": {
+ "name": "process_started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_output_at": {
+ "name": "last_output_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_output_seq": {
+ "name": "last_output_seq",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_output_stream": {
+ "name": "last_output_stream",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_output_bytes": {
+ "name": "last_output_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retry_of_run_id": {
+ "name": "retry_of_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "process_loss_retry_count": {
+ "name": "process_loss_retry_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "scheduled_retry_at": {
+ "name": "scheduled_retry_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scheduled_retry_attempt": {
+ "name": "scheduled_retry_attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "scheduled_retry_reason": {
+ "name": "scheduled_retry_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_comment_status": {
+ "name": "issue_comment_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'not_applicable'"
+ },
+ "issue_comment_satisfied_by_comment_id": {
+ "name": "issue_comment_satisfied_by_comment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_comment_retry_queued_at": {
+ "name": "issue_comment_retry_queued_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "liveness_state": {
+ "name": "liveness_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "liveness_reason": {
+ "name": "liveness_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "continuation_attempt": {
+ "name": "continuation_attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_useful_action_at": {
+ "name": "last_useful_action_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_action": {
+ "name": "next_action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "context_snapshot": {
+ "name": "context_snapshot",
+ "type": "jsonb",
+ "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": {
+ "heartbeat_runs_company_agent_started_idx": {
+ "name": "heartbeat_runs_company_agent_started_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_responsible_user_idx": {
+ "name": "heartbeat_runs_company_responsible_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "responsible_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_liveness_idx": {
+ "name": "heartbeat_runs_company_liveness_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "liveness_state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_status_last_output_idx": {
+ "name": "heartbeat_runs_company_status_last_output_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "last_output_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_status_process_started_idx": {
+ "name": "heartbeat_runs_company_status_process_started_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "process_started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_created_at_desc_idx": {
+ "name": "heartbeat_runs_company_created_at_desc_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_ctx_issue_created_idx": {
+ "name": "heartbeat_runs_company_ctx_issue_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "(\"context_snapshot\" ->> 'issueId')",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_ctx_task_created_idx": {
+ "name": "heartbeat_runs_company_ctx_task_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "(\"context_snapshot\" ->> 'taskId')",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "heartbeat_runs_company_ctx_taskkey_created_idx": {
+ "name": "heartbeat_runs_company_ctx_taskkey_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "(\"context_snapshot\" ->> 'taskKey')",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "heartbeat_runs_company_id_companies_id_fk": {
+ "name": "heartbeat_runs_company_id_companies_id_fk",
+ "tableFrom": "heartbeat_runs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "heartbeat_runs_agent_id_agents_id_fk": {
+ "name": "heartbeat_runs_agent_id_agents_id_fk",
+ "tableFrom": "heartbeat_runs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk": {
+ "name": "heartbeat_runs_wakeup_request_id_agent_wakeup_requests_id_fk",
+ "tableFrom": "heartbeat_runs",
+ "tableTo": "agent_wakeup_requests",
+ "columnsFrom": [
+ "wakeup_request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": {
+ "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "heartbeat_runs",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "retry_of_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.inbox_dismissals": {
+ "name": "inbox_dismissals",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "item_key": {
+ "name": "item_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dismiss'"
+ },
+ "dismissed_at": {
+ "name": "dismissed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "snoozed_until": {
+ "name": "snoozed_until",
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "inbox_dismissals_company_user_idx": {
+ "name": "inbox_dismissals_company_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "inbox_dismissals_company_item_idx": {
+ "name": "inbox_dismissals_company_item_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "item_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "inbox_dismissals_company_user_item_idx": {
+ "name": "inbox_dismissals_company_user_item_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "item_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "inbox_dismissals_company_id_companies_id_fk": {
+ "name": "inbox_dismissals_company_id_companies_id_fk",
+ "tableFrom": "inbox_dismissals",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.connection_grants": {
+ "name": "connection_grants",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject_user_id": {
+ "name": "subject_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_tenant": {
+ "name": "provider_tenant",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_secret_refs": {
+ "name": "credential_secret_refs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "is_default": {
+ "name": "is_default",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_by_agent_id": {
+ "name": "revoked_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_by_user_id": {
+ "name": "revoked_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_at": {
+ "name": "last_used_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "connection_grants_company_connection_idx": {
+ "name": "connection_grants_company_connection_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "connection_grants_subject_user_idx": {
+ "name": "connection_grants_subject_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "subject_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "connection_grants_user_uq": {
+ "name": "connection_grants_user_uq",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "subject_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "connection_grants_default_uq": {
+ "name": "connection_grants_default_uq",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"connection_grants\".\"is_default\" = true and \"connection_grants\".\"kind\" = 'workspace'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "connection_grants_company_id_companies_id_fk": {
+ "name": "connection_grants_company_id_companies_id_fk",
+ "tableFrom": "connection_grants",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "connection_grants_created_by_agent_id_agents_id_fk": {
+ "name": "connection_grants_created_by_agent_id_agents_id_fk",
+ "tableFrom": "connection_grants",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "connection_grants_revoked_by_agent_id_agents_id_fk": {
+ "name": "connection_grants_revoked_by_agent_id_agents_id_fk",
+ "tableFrom": "connection_grants",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "revoked_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "connection_grants_company_connection_fk": {
+ "name": "connection_grants_company_connection_fk",
+ "tableFrom": "connection_grants",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "company_id",
+ "connection_id"
+ ],
+ "columnsTo": [
+ "company_id",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "connection_grants_kind_check": {
+ "name": "connection_grants_kind_check",
+ "value": "\"connection_grants\".\"kind\" in ('workspace', 'user')"
+ },
+ "connection_grants_status_check": {
+ "name": "connection_grants_status_check",
+ "value": "\"connection_grants\".\"status\" in ('active', 'revoked', 'expired', 'needs_reauthorization')"
+ },
+ "connection_grants_subject_check": {
+ "name": "connection_grants_subject_check",
+ "value": "(\"connection_grants\".\"kind\" = 'user' and \"connection_grants\".\"subject_user_id\" is not null) or (\"connection_grants\".\"kind\" = 'workspace' and \"connection_grants\".\"subject_user_id\" is null)"
+ },
+ "connection_grants_default_check": {
+ "name": "connection_grants_default_check",
+ "value": "\"connection_grants\".\"is_default\" = false or \"connection_grants\".\"kind\" = 'workspace'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.connection_token_issuances": {
+ "name": "connection_token_issuances",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_scope": {
+ "name": "requested_scope",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "issued_scope": {
+ "name": "issued_scope",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "ttl_seconds": {
+ "name": "ttl_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "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": {
+ "connection_token_issuances_company_created_idx": {
+ "name": "connection_token_issuances_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "connection_token_issuances_connection_created_idx": {
+ "name": "connection_token_issuances_connection_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "connection_token_issuances_agent_connection_idx": {
+ "name": "connection_token_issuances_agent_connection_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "connection_token_issuances_run_idx": {
+ "name": "connection_token_issuances_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "connection_token_issuances_company_id_companies_id_fk": {
+ "name": "connection_token_issuances_company_id_companies_id_fk",
+ "tableFrom": "connection_token_issuances",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "connection_token_issuances_application_id_tool_applications_id_fk": {
+ "name": "connection_token_issuances_application_id_tool_applications_id_fk",
+ "tableFrom": "connection_token_issuances",
+ "tableTo": "tool_applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "connection_token_issuances_connection_id_tool_connections_id_fk": {
+ "name": "connection_token_issuances_connection_id_tool_connections_id_fk",
+ "tableFrom": "connection_token_issuances",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "connection_token_issuances_agent_id_agents_id_fk": {
+ "name": "connection_token_issuances_agent_id_agents_id_fk",
+ "tableFrom": "connection_token_issuances",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "connection_token_issuances_run_id_heartbeat_runs_id_fk": {
+ "name": "connection_token_issuances_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "connection_token_issuances",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "connection_token_issuances_issue_id_issues_id_fk": {
+ "name": "connection_token_issuances_issue_id_issues_id_fk",
+ "tableFrom": "connection_token_issuances",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "connection_token_issuances_project_id_projects_id_fk": {
+ "name": "connection_token_issuances_project_id_projects_id_fk",
+ "tableFrom": "connection_token_issuances",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.instance_settings": {
+ "name": "instance_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "singleton_key": {
+ "name": "singleton_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "default_environment_id": {
+ "name": "default_environment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "general": {
+ "name": "general",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "experimental": {
+ "name": "experimental",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "instance_settings_singleton_key_idx": {
+ "name": "instance_settings_singleton_key_idx",
+ "columns": [
+ {
+ "expression": "singleton_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "instance_settings_default_environment_id_environments_id_fk": {
+ "name": "instance_settings_default_environment_id_environments_id_fk",
+ "tableFrom": "instance_settings",
+ "tableTo": "environments",
+ "columnsFrom": [
+ "default_environment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.instance_user_roles": {
+ "name": "instance_user_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'instance_admin'"
+ },
+ "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": {
+ "instance_user_roles_user_role_unique_idx": {
+ "name": "instance_user_roles_user_role_unique_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "instance_user_roles_role_idx": {
+ "name": "instance_user_roles_role_idx",
+ "columns": [
+ {
+ "expression": "role",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invites": {
+ "name": "invites",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "invite_type": {
+ "name": "invite_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'company_join'"
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allowed_join_types": {
+ "name": "allowed_join_types",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'both'"
+ },
+ "defaults_payload": {
+ "name": "defaults_payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invited_by_user_id": {
+ "name": "invited_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accepted_at": {
+ "name": "accepted_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "invites_token_hash_unique_idx": {
+ "name": "invites_token_hash_unique_idx",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "invites_company_invite_state_idx": {
+ "name": "invites_company_invite_state_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "invite_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "revoked_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "invites_company_id_companies_id_fk": {
+ "name": "invites_company_id_companies_id_fk",
+ "tableFrom": "invites",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_approvals": {
+ "name": "issue_approvals",
+ "schema": "",
+ "columns": {
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "approval_id": {
+ "name": "approval_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linked_by_agent_id": {
+ "name": "linked_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "linked_by_user_id": {
+ "name": "linked_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_approvals_issue_idx": {
+ "name": "issue_approvals_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_approvals_approval_idx": {
+ "name": "issue_approvals_approval_idx",
+ "columns": [
+ {
+ "expression": "approval_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_approvals_company_idx": {
+ "name": "issue_approvals_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_approvals_company_id_companies_id_fk": {
+ "name": "issue_approvals_company_id_companies_id_fk",
+ "tableFrom": "issue_approvals",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_approvals_issue_id_issues_id_fk": {
+ "name": "issue_approvals_issue_id_issues_id_fk",
+ "tableFrom": "issue_approvals",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_approvals_approval_id_approvals_id_fk": {
+ "name": "issue_approvals_approval_id_approvals_id_fk",
+ "tableFrom": "issue_approvals",
+ "tableTo": "approvals",
+ "columnsFrom": [
+ "approval_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_approvals_linked_by_agent_id_agents_id_fk": {
+ "name": "issue_approvals_linked_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_approvals",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "linked_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_approvals_pk": {
+ "name": "issue_approvals_pk",
+ "columns": [
+ "issue_id",
+ "approval_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_attachments": {
+ "name": "issue_attachments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "asset_id": {
+ "name": "asset_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_comment_id": {
+ "name": "issue_comment_id",
+ "type": "uuid",
+ "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": {
+ "issue_attachments_company_issue_idx": {
+ "name": "issue_attachments_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_attachments_issue_comment_idx": {
+ "name": "issue_attachments_issue_comment_idx",
+ "columns": [
+ {
+ "expression": "issue_comment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_attachments_asset_uq": {
+ "name": "issue_attachments_asset_uq",
+ "columns": [
+ {
+ "expression": "asset_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_attachments_company_id_companies_id_fk": {
+ "name": "issue_attachments_company_id_companies_id_fk",
+ "tableFrom": "issue_attachments",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_attachments_issue_id_issues_id_fk": {
+ "name": "issue_attachments_issue_id_issues_id_fk",
+ "tableFrom": "issue_attachments",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_attachments_asset_id_assets_id_fk": {
+ "name": "issue_attachments_asset_id_assets_id_fk",
+ "tableFrom": "issue_attachments",
+ "tableTo": "assets",
+ "columnsFrom": [
+ "asset_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_attachments_issue_comment_id_issue_comments_id_fk": {
+ "name": "issue_attachments_issue_comment_id_issue_comments_id_fk",
+ "tableFrom": "issue_attachments",
+ "tableTo": "issue_comments",
+ "columnsFrom": [
+ "issue_comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_comments": {
+ "name": "issue_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_agent_id": {
+ "name": "author_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_user_id": {
+ "name": "author_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "on_behalf_of_user_id": {
+ "name": "on_behalf_of_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_type": {
+ "name": "author_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "derived_author_agent_id": {
+ "name": "derived_author_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "derived_created_by_run_id": {
+ "name": "derived_created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "derived_author_source": {
+ "name": "derived_author_source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "presentation": {
+ "name": "presentation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_by_type": {
+ "name": "deleted_by_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_by_agent_id": {
+ "name": "deleted_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_by_user_id": {
+ "name": "deleted_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_by_run_id": {
+ "name": "deleted_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_trust": {
+ "name": "source_trust",
+ "type": "jsonb",
+ "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": {
+ "issue_comments_issue_idx": {
+ "name": "issue_comments_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_comments_company_idx": {
+ "name": "issue_comments_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_comments_company_issue_created_at_idx": {
+ "name": "issue_comments_company_issue_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_comments_company_author_issue_created_at_idx": {
+ "name": "issue_comments_company_author_issue_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "author_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_comments_body_search_idx": {
+ "name": "issue_comments_body_search_idx",
+ "columns": [
+ {
+ "expression": "body",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_comments_company_id_companies_id_fk": {
+ "name": "issue_comments_company_id_companies_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_comments_issue_id_issues_id_fk": {
+ "name": "issue_comments_issue_id_issues_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_comments_author_agent_id_agents_id_fk": {
+ "name": "issue_comments_author_agent_id_agents_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "author_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_comments_on_behalf_of_user_id_user_id_fk": {
+ "name": "issue_comments_on_behalf_of_user_id_user_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "user",
+ "columnsFrom": [
+ "on_behalf_of_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_comments_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_comments_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_comments_derived_author_agent_id_agents_id_fk": {
+ "name": "issue_comments_derived_author_agent_id_agents_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "derived_author_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_comments_derived_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "derived_created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_comments_deleted_by_agent_id_agents_id_fk": {
+ "name": "issue_comments_deleted_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "deleted_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_comments_deleted_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_comments",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "deleted_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_create_idempotency_keys": {
+ "name": "issue_create_idempotency_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_create_idempotency_keys_company_key_uq": {
+ "name": "issue_create_idempotency_keys_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_create_idempotency_keys_issue_idx": {
+ "name": "issue_create_idempotency_keys_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_create_idempotency_keys_company_created_at_idx": {
+ "name": "issue_create_idempotency_keys_company_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_create_idempotency_keys_company_id_companies_id_fk": {
+ "name": "issue_create_idempotency_keys_company_id_companies_id_fk",
+ "tableFrom": "issue_create_idempotency_keys",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_create_idempotency_keys_issue_id_issues_id_fk": {
+ "name": "issue_create_idempotency_keys_issue_id_issues_id_fk",
+ "tableFrom": "issue_create_idempotency_keys",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_documents": {
+ "name": "issue_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "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": {
+ "issue_documents_company_issue_key_uq": {
+ "name": "issue_documents_company_issue_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_documents_document_uq": {
+ "name": "issue_documents_document_uq",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_documents_company_issue_updated_idx": {
+ "name": "issue_documents_company_issue_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_documents_company_id_companies_id_fk": {
+ "name": "issue_documents_company_id_companies_id_fk",
+ "tableFrom": "issue_documents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_documents_issue_id_issues_id_fk": {
+ "name": "issue_documents_issue_id_issues_id_fk",
+ "tableFrom": "issue_documents",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_documents_document_id_documents_id_fk": {
+ "name": "issue_documents_document_id_documents_id_fk",
+ "tableFrom": "issue_documents",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_execution_decisions": {
+ "name": "issue_execution_decisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stage_id": {
+ "name": "stage_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stage_type": {
+ "name": "stage_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_agent_id": {
+ "name": "actor_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "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": {
+ "issue_execution_decisions_company_issue_idx": {
+ "name": "issue_execution_decisions_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_execution_decisions_stage_idx": {
+ "name": "issue_execution_decisions_stage_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "stage_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_execution_decisions_company_id_companies_id_fk": {
+ "name": "issue_execution_decisions_company_id_companies_id_fk",
+ "tableFrom": "issue_execution_decisions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_execution_decisions_issue_id_issues_id_fk": {
+ "name": "issue_execution_decisions_issue_id_issues_id_fk",
+ "tableFrom": "issue_execution_decisions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_execution_decisions_actor_agent_id_agents_id_fk": {
+ "name": "issue_execution_decisions_actor_agent_id_agents_id_fk",
+ "tableFrom": "issue_execution_decisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "actor_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_execution_decisions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_inbox_archives": {
+ "name": "issue_inbox_archives",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "archived_by_actor_type": {
+ "name": "archived_by_actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "archived_by_agent_id": {
+ "name": "archived_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_by_run_id": {
+ "name": "archived_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "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": {
+ "issue_inbox_archives_company_issue_idx": {
+ "name": "issue_inbox_archives_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_inbox_archives_company_user_idx": {
+ "name": "issue_inbox_archives_company_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_inbox_archives_company_issue_user_idx": {
+ "name": "issue_inbox_archives_company_issue_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_inbox_archives_company_id_companies_id_fk": {
+ "name": "issue_inbox_archives_company_id_companies_id_fk",
+ "tableFrom": "issue_inbox_archives",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_inbox_archives_issue_id_issues_id_fk": {
+ "name": "issue_inbox_archives_issue_id_issues_id_fk",
+ "tableFrom": "issue_inbox_archives",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_inbox_archives_archived_by_agent_id_agents_id_fk": {
+ "name": "issue_inbox_archives_archived_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_inbox_archives",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "archived_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_inbox_archives_archived_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_inbox_archives",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "archived_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "issue_inbox_archives_archived_by_actor_type_check": {
+ "name": "issue_inbox_archives_archived_by_actor_type_check",
+ "value": "\"issue_inbox_archives\".\"archived_by_actor_type\" in ('user', 'agent')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.issue_labels": {
+ "name": "issue_labels",
+ "schema": "",
+ "columns": {
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label_id": {
+ "name": "label_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_labels_issue_idx": {
+ "name": "issue_labels_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_labels_label_idx": {
+ "name": "issue_labels_label_idx",
+ "columns": [
+ {
+ "expression": "label_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_labels_company_idx": {
+ "name": "issue_labels_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_labels_issue_id_issues_id_fk": {
+ "name": "issue_labels_issue_id_issues_id_fk",
+ "tableFrom": "issue_labels",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_labels_label_id_labels_id_fk": {
+ "name": "issue_labels_label_id_labels_id_fk",
+ "tableFrom": "issue_labels",
+ "tableTo": "labels",
+ "columnsFrom": [
+ "label_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_labels_company_id_companies_id_fk": {
+ "name": "issue_labels_company_id_companies_id_fk",
+ "tableFrom": "issue_labels",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "issue_labels_pk": {
+ "name": "issue_labels_pk",
+ "columns": [
+ "issue_id",
+ "label_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_plan_decompositions": {
+ "name": "issue_plan_decompositions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_issue_id": {
+ "name": "source_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accepted_plan_revision_id": {
+ "name": "accepted_plan_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "accepted_interaction_id": {
+ "name": "accepted_interaction_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'in_flight'"
+ },
+ "request_fingerprint": {
+ "name": "request_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requested_child_count": {
+ "name": "requested_child_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "requested_children": {
+ "name": "requested_children",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "child_issue_ids": {
+ "name": "child_issue_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "owner_agent_id": {
+ "name": "owner_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_run_id": {
+ "name": "owner_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_plan_decompositions_company_source_status_idx": {
+ "name": "issue_plan_decompositions_company_source_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_plan_decompositions_active_owner_idx": {
+ "name": "issue_plan_decompositions_active_owner_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"issue_plan_decompositions\".\"status\" = 'in_flight'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_plan_decompositions_source_revision_uq": {
+ "name": "issue_plan_decompositions_source_revision_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "accepted_plan_revision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_plan_decompositions_company_id_companies_id_fk": {
+ "name": "issue_plan_decompositions_company_id_companies_id_fk",
+ "tableFrom": "issue_plan_decompositions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_plan_decompositions_source_issue_id_issues_id_fk": {
+ "name": "issue_plan_decompositions_source_issue_id_issues_id_fk",
+ "tableFrom": "issue_plan_decompositions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "source_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk": {
+ "name": "issue_plan_decompositions_accepted_plan_revision_id_document_revisions_id_fk",
+ "tableFrom": "issue_plan_decompositions",
+ "tableTo": "document_revisions",
+ "columnsFrom": [
+ "accepted_plan_revision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk": {
+ "name": "issue_plan_decompositions_accepted_interaction_id_issue_thread_interactions_id_fk",
+ "tableFrom": "issue_plan_decompositions",
+ "tableTo": "issue_thread_interactions",
+ "columnsFrom": [
+ "accepted_interaction_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_plan_decompositions_owner_agent_id_agents_id_fk": {
+ "name": "issue_plan_decompositions_owner_agent_id_agents_id_fk",
+ "tableFrom": "issue_plan_decompositions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "owner_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_plan_decompositions_owner_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_plan_decompositions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "owner_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_read_states": {
+ "name": "issue_read_states",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "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": {
+ "issue_read_states_company_issue_idx": {
+ "name": "issue_read_states_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_read_states_company_user_idx": {
+ "name": "issue_read_states_company_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_read_states_company_issue_user_idx": {
+ "name": "issue_read_states_company_issue_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_read_states_company_id_companies_id_fk": {
+ "name": "issue_read_states_company_id_companies_id_fk",
+ "tableFrom": "issue_read_states",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_read_states_issue_id_issues_id_fk": {
+ "name": "issue_read_states_issue_id_issues_id_fk",
+ "tableFrom": "issue_read_states",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_recovery_actions": {
+ "name": "issue_recovery_actions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_issue_id": {
+ "name": "source_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recovery_issue_id": {
+ "name": "recovery_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "owner_type": {
+ "name": "owner_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'agent'"
+ },
+ "owner_agent_id": {
+ "name": "owner_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "previous_owner_agent_id": {
+ "name": "previous_owner_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "return_owner_agent_id": {
+ "name": "return_owner_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cause": {
+ "name": "cause",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "evidence": {
+ "name": "evidence",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "next_action": {
+ "name": "next_action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "wake_policy": {
+ "name": "wake_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monitor_policy": {
+ "name": "monitor_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempt_count": {
+ "name": "attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "max_attempts": {
+ "name": "max_attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_at": {
+ "name": "timeout_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_attempt_at": {
+ "name": "last_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolution_note": {
+ "name": "resolution_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_recovery_actions_company_source_status_idx": {
+ "name": "issue_recovery_actions_company_source_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_recovery_actions_company_owner_status_idx": {
+ "name": "issue_recovery_actions_company_owner_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "owner_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_recovery_actions_company_recovery_issue_idx": {
+ "name": "issue_recovery_actions_company_recovery_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "recovery_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_recovery_actions_active_source_uq": {
+ "name": "issue_recovery_actions_active_source_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_recovery_actions_active_fingerprint_uq": {
+ "name": "issue_recovery_actions_active_fingerprint_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "cause",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issue_recovery_actions\".\"status\" in ('active', 'escalated')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_recovery_actions_company_id_companies_id_fk": {
+ "name": "issue_recovery_actions_company_id_companies_id_fk",
+ "tableFrom": "issue_recovery_actions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_recovery_actions_source_issue_id_issues_id_fk": {
+ "name": "issue_recovery_actions_source_issue_id_issues_id_fk",
+ "tableFrom": "issue_recovery_actions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "source_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_recovery_actions_recovery_issue_id_issues_id_fk": {
+ "name": "issue_recovery_actions_recovery_issue_id_issues_id_fk",
+ "tableFrom": "issue_recovery_actions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "recovery_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_recovery_actions_owner_agent_id_agents_id_fk": {
+ "name": "issue_recovery_actions_owner_agent_id_agents_id_fk",
+ "tableFrom": "issue_recovery_actions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "owner_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_recovery_actions_previous_owner_agent_id_agents_id_fk": {
+ "name": "issue_recovery_actions_previous_owner_agent_id_agents_id_fk",
+ "tableFrom": "issue_recovery_actions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "previous_owner_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_recovery_actions_return_owner_agent_id_agents_id_fk": {
+ "name": "issue_recovery_actions_return_owner_agent_id_agents_id_fk",
+ "tableFrom": "issue_recovery_actions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "return_owner_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_reference_mentions": {
+ "name": "issue_reference_mentions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_issue_id": {
+ "name": "source_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_issue_id": {
+ "name": "target_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_kind": {
+ "name": "source_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_record_id": {
+ "name": "source_record_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "document_key": {
+ "name": "document_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "matched_text": {
+ "name": "matched_text",
+ "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": {
+ "issue_reference_mentions_company_source_issue_idx": {
+ "name": "issue_reference_mentions_company_source_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_reference_mentions_company_target_issue_idx": {
+ "name": "issue_reference_mentions_company_target_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_reference_mentions_company_issue_pair_idx": {
+ "name": "issue_reference_mentions_company_issue_pair_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_reference_mentions_company_source_mention_record_uq": {
+ "name": "issue_reference_mentions_company_source_mention_record_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_record_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issue_reference_mentions\".\"source_record_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_reference_mentions_company_source_mention_null_record_uq": {
+ "name": "issue_reference_mentions_company_source_mention_null_record_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issue_reference_mentions\".\"source_record_id\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_reference_mentions_company_id_companies_id_fk": {
+ "name": "issue_reference_mentions_company_id_companies_id_fk",
+ "tableFrom": "issue_reference_mentions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_reference_mentions_source_issue_id_issues_id_fk": {
+ "name": "issue_reference_mentions_source_issue_id_issues_id_fk",
+ "tableFrom": "issue_reference_mentions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "source_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_reference_mentions_target_issue_id_issues_id_fk": {
+ "name": "issue_reference_mentions_target_issue_id_issues_id_fk",
+ "tableFrom": "issue_reference_mentions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "target_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_relations": {
+ "name": "issue_relations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "related_issue_id": {
+ "name": "related_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "issue_relations_company_issue_idx": {
+ "name": "issue_relations_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_relations_company_related_issue_idx": {
+ "name": "issue_relations_company_related_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "related_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_relations_company_type_idx": {
+ "name": "issue_relations_company_type_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_relations_company_edge_uq": {
+ "name": "issue_relations_company_edge_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "related_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_relations_company_id_companies_id_fk": {
+ "name": "issue_relations_company_id_companies_id_fk",
+ "tableFrom": "issue_relations",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_relations_issue_id_issues_id_fk": {
+ "name": "issue_relations_issue_id_issues_id_fk",
+ "tableFrom": "issue_relations",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_relations_related_issue_id_issues_id_fk": {
+ "name": "issue_relations_related_issue_id_issues_id_fk",
+ "tableFrom": "issue_relations",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "related_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_relations_created_by_agent_id_agents_id_fk": {
+ "name": "issue_relations_created_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_relations",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_thread_interactions": {
+ "name": "issue_thread_interactions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "continuation_policy": {
+ "name": "continuation_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'wake_assignee'"
+ },
+ "requested_resolver_policy": {
+ "name": "requested_resolver_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'board_only'"
+ },
+ "effective_resolver_policy": {
+ "name": "effective_resolver_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'board_only'"
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_comment_id": {
+ "name": "source_comment_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_run_id": {
+ "name": "source_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "addressee_agent_id": {
+ "name": "addressee_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_agent_id": {
+ "name": "resolved_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_run_id": {
+ "name": "resolved_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_user_id": {
+ "name": "resolved_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "result": {
+ "name": "result",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_thread_interactions_issue_idx": {
+ "name": "issue_thread_interactions_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_thread_interactions_company_issue_created_at_idx": {
+ "name": "issue_thread_interactions_company_issue_created_at_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_thread_interactions_company_issue_status_idx": {
+ "name": "issue_thread_interactions_company_issue_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_thread_interactions_company_issue_idempotency_uq": {
+ "name": "issue_thread_interactions_company_issue_idempotency_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issue_thread_interactions\".\"idempotency_key\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_thread_interactions_source_comment_idx": {
+ "name": "issue_thread_interactions_source_comment_idx",
+ "columns": [
+ {
+ "expression": "source_comment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_thread_interactions_addressee_agent_idx": {
+ "name": "issue_thread_interactions_addressee_agent_idx",
+ "columns": [
+ {
+ "expression": "addressee_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_thread_interactions_company_id_companies_id_fk": {
+ "name": "issue_thread_interactions_company_id_companies_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_thread_interactions_issue_id_issues_id_fk": {
+ "name": "issue_thread_interactions_issue_id_issues_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_thread_interactions_source_comment_id_issue_comments_id_fk": {
+ "name": "issue_thread_interactions_source_comment_id_issue_comments_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "issue_comments",
+ "columnsFrom": [
+ "source_comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "source_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_thread_interactions_created_by_agent_id_agents_id_fk": {
+ "name": "issue_thread_interactions_created_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_thread_interactions_addressee_agent_id_agents_id_fk": {
+ "name": "issue_thread_interactions_addressee_agent_id_agents_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "addressee_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_thread_interactions_resolved_by_agent_id_agents_id_fk": {
+ "name": "issue_thread_interactions_resolved_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "resolved_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_thread_interactions_resolved_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_thread_interactions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "resolved_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_tree_hold_members": {
+ "name": "issue_tree_hold_members",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hold_id": {
+ "name": "hold_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_issue_id": {
+ "name": "parent_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "depth": {
+ "name": "depth",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "issue_identifier": {
+ "name": "issue_identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_title": {
+ "name": "issue_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_status": {
+ "name": "issue_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "assignee_agent_id": {
+ "name": "assignee_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignee_user_id": {
+ "name": "assignee_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "active_run_id": {
+ "name": "active_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "active_run_status": {
+ "name": "active_run_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "skipped": {
+ "name": "skipped",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "skip_reason": {
+ "name": "skip_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issue_tree_hold_members_hold_issue_uq": {
+ "name": "issue_tree_hold_members_hold_issue_uq",
+ "columns": [
+ {
+ "expression": "hold_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_tree_hold_members_company_issue_idx": {
+ "name": "issue_tree_hold_members_company_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_tree_hold_members_hold_depth_idx": {
+ "name": "issue_tree_hold_members_hold_depth_idx",
+ "columns": [
+ {
+ "expression": "hold_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "depth",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_tree_hold_members_company_id_companies_id_fk": {
+ "name": "issue_tree_hold_members_company_id_companies_id_fk",
+ "tableFrom": "issue_tree_hold_members",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk": {
+ "name": "issue_tree_hold_members_hold_id_issue_tree_holds_id_fk",
+ "tableFrom": "issue_tree_hold_members",
+ "tableTo": "issue_tree_holds",
+ "columnsFrom": [
+ "hold_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_tree_hold_members_issue_id_issues_id_fk": {
+ "name": "issue_tree_hold_members_issue_id_issues_id_fk",
+ "tableFrom": "issue_tree_hold_members",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_tree_hold_members_parent_issue_id_issues_id_fk": {
+ "name": "issue_tree_hold_members_parent_issue_id_issues_id_fk",
+ "tableFrom": "issue_tree_hold_members",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "parent_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_tree_hold_members_assignee_agent_id_agents_id_fk": {
+ "name": "issue_tree_hold_members_assignee_agent_id_agents_id_fk",
+ "tableFrom": "issue_tree_hold_members",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "assignee_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_tree_hold_members_active_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_tree_hold_members",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "active_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_tree_holds": {
+ "name": "issue_tree_holds",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "root_issue_id": {
+ "name": "root_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_policy": {
+ "name": "release_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_actor_type": {
+ "name": "created_by_actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "released_at": {
+ "name": "released_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "released_by_actor_type": {
+ "name": "released_by_actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "released_by_agent_id": {
+ "name": "released_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "released_by_user_id": {
+ "name": "released_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "released_by_run_id": {
+ "name": "released_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_reason": {
+ "name": "release_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "release_metadata": {
+ "name": "release_metadata",
+ "type": "jsonb",
+ "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": {
+ "issue_tree_holds_company_root_status_idx": {
+ "name": "issue_tree_holds_company_root_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "root_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_tree_holds_company_status_mode_idx": {
+ "name": "issue_tree_holds_company_status_mode_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "mode",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_tree_holds_company_id_companies_id_fk": {
+ "name": "issue_tree_holds_company_id_companies_id_fk",
+ "tableFrom": "issue_tree_holds",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_tree_holds_root_issue_id_issues_id_fk": {
+ "name": "issue_tree_holds_root_issue_id_issues_id_fk",
+ "tableFrom": "issue_tree_holds",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "root_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_tree_holds_created_by_agent_id_agents_id_fk": {
+ "name": "issue_tree_holds_created_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_tree_holds",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_tree_holds_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_tree_holds",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_tree_holds_released_by_agent_id_agents_id_fk": {
+ "name": "issue_tree_holds_released_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_tree_holds",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "released_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_tree_holds_released_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_tree_holds",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "released_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_watchdogs": {
+ "name": "issue_watchdogs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "watchdog_agent_id": {
+ "name": "watchdog_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instructions": {
+ "name": "instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "watchdog_issue_id": {
+ "name": "watchdog_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_observed_fingerprint": {
+ "name": "last_observed_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_reviewed_fingerprint": {
+ "name": "last_reviewed_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_observed_stop_snapshot": {
+ "name": "last_observed_stop_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_reviewed_stop_snapshot": {
+ "name": "last_reviewed_stop_snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_triggered_at": {
+ "name": "last_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_completed_at": {
+ "name": "last_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trigger_count": {
+ "name": "trigger_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_agent_id": {
+ "name": "updated_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_run_id": {
+ "name": "updated_by_run_id",
+ "type": "uuid",
+ "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": {
+ "issue_watchdogs_company_issue_uq": {
+ "name": "issue_watchdogs_company_issue_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_watchdogs_company_status_idx": {
+ "name": "issue_watchdogs_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_watchdogs_company_agent_idx": {
+ "name": "issue_watchdogs_company_agent_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "watchdog_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_watchdogs_company_watchdog_issue_uq": {
+ "name": "issue_watchdogs_company_watchdog_issue_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "watchdog_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issue_watchdogs\".\"watchdog_issue_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_watchdogs_company_id_companies_id_fk": {
+ "name": "issue_watchdogs_company_id_companies_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_watchdogs_issue_id_issues_id_fk": {
+ "name": "issue_watchdogs_issue_id_issues_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_watchdogs_watchdog_agent_id_agents_id_fk": {
+ "name": "issue_watchdogs_watchdog_agent_id_agents_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "watchdog_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_watchdogs_watchdog_issue_id_issues_id_fk": {
+ "name": "issue_watchdogs_watchdog_issue_id_issues_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "watchdog_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_watchdogs_created_by_agent_id_agents_id_fk": {
+ "name": "issue_watchdogs_created_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_watchdogs_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_watchdogs_updated_by_agent_id_agents_id_fk": {
+ "name": "issue_watchdogs_updated_by_agent_id_agents_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "updated_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_watchdogs_updated_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_watchdogs",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "updated_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issue_work_products": {
+ "name": "issue_work_products",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_workspace_id": {
+ "name": "execution_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "runtime_service_id": {
+ "name": "runtime_service_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "review_state": {
+ "name": "review_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "is_primary": {
+ "name": "is_primary",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "health_status": {
+ "name": "health_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_trust": {
+ "name": "source_trust",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "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": {
+ "issue_work_products_company_issue_type_idx": {
+ "name": "issue_work_products_company_issue_type_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_work_products_company_execution_workspace_type_idx": {
+ "name": "issue_work_products_company_execution_workspace_type_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "execution_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_work_products_company_provider_external_id_idx": {
+ "name": "issue_work_products_company_provider_external_id_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "external_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issue_work_products_company_updated_idx": {
+ "name": "issue_work_products_company_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issue_work_products_company_id_companies_id_fk": {
+ "name": "issue_work_products_company_id_companies_id_fk",
+ "tableFrom": "issue_work_products",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issue_work_products_project_id_projects_id_fk": {
+ "name": "issue_work_products_project_id_projects_id_fk",
+ "tableFrom": "issue_work_products",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_work_products_issue_id_issues_id_fk": {
+ "name": "issue_work_products_issue_id_issues_id_fk",
+ "tableFrom": "issue_work_products",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": {
+ "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk",
+ "tableFrom": "issue_work_products",
+ "tableTo": "execution_workspaces",
+ "columnsFrom": [
+ "execution_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": {
+ "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk",
+ "tableFrom": "issue_work_products",
+ "tableTo": "workspace_runtime_services",
+ "columnsFrom": [
+ "runtime_service_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issue_work_products",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.issues": {
+ "name": "issues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_workspace_id": {
+ "name": "project_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "goal_id": {
+ "name": "goal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "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": "'backlog'"
+ },
+ "work_mode": {
+ "name": "work_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'standard'"
+ },
+ "harness_kind": {
+ "name": "harness_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "review_policy": {
+ "name": "review_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignee_agent_id": {
+ "name": "assignee_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignee_user_id": {
+ "name": "assignee_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "checkout_run_id": {
+ "name": "checkout_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_run_id": {
+ "name": "execution_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_agent_name_key": {
+ "name": "execution_agent_name_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_locked_at": {
+ "name": "execution_locked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_number": {
+ "name": "issue_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "origin_id": {
+ "name": "origin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_run_id": {
+ "name": "origin_run_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_fingerprint": {
+ "name": "origin_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "request_depth": {
+ "name": "request_depth",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "billing_code": {
+ "name": "billing_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignee_adapter_overrides": {
+ "name": "assignee_adapter_overrides",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_policy": {
+ "name": "execution_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_state": {
+ "name": "execution_state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monitor_next_check_at": {
+ "name": "monitor_next_check_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monitor_wake_requested_at": {
+ "name": "monitor_wake_requested_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monitor_last_triggered_at": {
+ "name": "monitor_last_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monitor_attempt_count": {
+ "name": "monitor_attempt_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "monitor_notes": {
+ "name": "monitor_notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "monitor_scheduled_by": {
+ "name": "monitor_scheduled_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_workspace_id": {
+ "name": "execution_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_workspace_preference": {
+ "name": "execution_workspace_preference",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_workspace_settings": {
+ "name": "execution_workspace_settings",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source_trust": {
+ "name": "source_trust",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "unblock_descriptor": {
+ "name": "unblock_descriptor",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "blocked_transition_at": {
+ "name": "blocked_transition_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "blocked_owner_notified_at": {
+ "name": "blocked_owner_notified_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cancelled_at": {
+ "name": "cancelled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hidden_at": {
+ "name": "hidden_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "issues_company_status_idx": {
+ "name": "issues_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_harness_kind_idx": {
+ "name": "issues_company_harness_kind_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "harness_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_assignee_status_idx": {
+ "name": "issues_company_assignee_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "assignee_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_assignee_user_status_idx": {
+ "name": "issues_company_assignee_user_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "assignee_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_responsible_user_idx": {
+ "name": "issues_company_responsible_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "responsible_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_parent_idx": {
+ "name": "issues_company_parent_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_project_idx": {
+ "name": "issues_company_project_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_origin_idx": {
+ "name": "issues_company_origin_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_project_workspace_idx": {
+ "name": "issues_company_project_workspace_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_execution_workspace_idx": {
+ "name": "issues_company_execution_workspace_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "execution_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_monitor_due_idx": {
+ "name": "issues_company_monitor_due_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "monitor_next_check_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_updated_idx": {
+ "name": "issues_company_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_created_idx": {
+ "name": "issues_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_open_normalized_title_created_idx": {
+ "name": "issues_open_normalized_title_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "parent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "lower(regexp_replace(btrim(\"title\"), '\\s+', ' ', 'g'))",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"issues\".\"hidden_at\" is null and \"issues\".\"status\" not in ('done', 'cancelled')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_company_priority_idx": {
+ "name": "issues_company_priority_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "priority",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_identifier_idx": {
+ "name": "issues_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_title_search_idx": {
+ "name": "issues_title_search_idx",
+ "columns": [
+ {
+ "expression": "title",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "issues_identifier_search_idx": {
+ "name": "issues_identifier_search_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "issues_description_search_idx": {
+ "name": "issues_description_search_idx",
+ "columns": [
+ {
+ "expression": "description",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last",
+ "opclass": "gin_trgm_ops"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ },
+ "issues_open_routine_execution_uq": {
+ "name": "issues_open_routine_execution_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_active_liveness_recovery_incident_uq": {
+ "name": "issues_active_liveness_recovery_incident_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_active_liveness_recovery_leaf_uq": {
+ "name": "issues_active_liveness_recovery_leaf_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issues\".\"origin_kind\" = 'harness_liveness_escalation'\n and \"issues\".\"origin_fingerprint\" <> 'default'\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_active_stale_run_evaluation_uq": {
+ "name": "issues_active_stale_run_evaluation_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issues\".\"origin_kind\" = 'stale_active_run_evaluation'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_active_task_watchdog_uq": {
+ "name": "issues_active_task_watchdog_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issues\".\"origin_kind\" = 'task_watchdog'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_active_productivity_review_uq": {
+ "name": "issues_active_productivity_review_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issues\".\"origin_kind\" = 'issue_productivity_review'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "issues_active_stranded_issue_recovery_uq": {
+ "name": "issues_active_stranded_issue_recovery_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"issues\".\"origin_kind\" = 'stranded_issue_recovery'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "issues_company_id_companies_id_fk": {
+ "name": "issues_company_id_companies_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issues_project_id_projects_id_fk": {
+ "name": "issues_project_id_projects_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issues_project_workspace_id_project_workspaces_id_fk": {
+ "name": "issues_project_workspace_id_project_workspaces_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "project_workspaces",
+ "columnsFrom": [
+ "project_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issues_goal_id_goals_id_fk": {
+ "name": "issues_goal_id_goals_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "goals",
+ "columnsFrom": [
+ "goal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issues_parent_id_issues_id_fk": {
+ "name": "issues_parent_id_issues_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "parent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issues_assignee_agent_id_agents_id_fk": {
+ "name": "issues_assignee_agent_id_agents_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "assignee_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issues_checkout_run_id_heartbeat_runs_id_fk": {
+ "name": "issues_checkout_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "checkout_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issues_execution_run_id_heartbeat_runs_id_fk": {
+ "name": "issues_execution_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "execution_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "issues_created_by_agent_id_agents_id_fk": {
+ "name": "issues_created_by_agent_id_agents_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "issues_execution_workspace_id_execution_workspaces_id_fk": {
+ "name": "issues_execution_workspace_id_execution_workspaces_id_fk",
+ "tableFrom": "issues",
+ "tableTo": "execution_workspaces",
+ "columnsFrom": [
+ "execution_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.join_requests": {
+ "name": "join_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "invite_id": {
+ "name": "invite_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "request_type": {
+ "name": "request_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending_approval'"
+ },
+ "request_ip": {
+ "name": "request_ip",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requesting_user_id": {
+ "name": "requesting_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "request_email_snapshot": {
+ "name": "request_email_snapshot",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_name": {
+ "name": "agent_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "adapter_type": {
+ "name": "adapter_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_defaults_payload": {
+ "name": "agent_defaults_payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claim_secret_hash": {
+ "name": "claim_secret_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claim_secret_expires_at": {
+ "name": "claim_secret_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "claim_secret_consumed_at": {
+ "name": "claim_secret_consumed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_agent_id": {
+ "name": "created_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_by_user_id": {
+ "name": "approved_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approved_at": {
+ "name": "approved_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_by_user_id": {
+ "name": "rejected_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rejected_at": {
+ "name": "rejected_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "join_requests_invite_unique_idx": {
+ "name": "join_requests_invite_unique_idx",
+ "columns": [
+ {
+ "expression": "invite_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "join_requests_company_status_type_created_idx": {
+ "name": "join_requests_company_status_type_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "request_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "join_requests_pending_human_user_uq": {
+ "name": "join_requests_pending_human_user_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "requesting_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "join_requests_pending_human_email_uq": {
+ "name": "join_requests_pending_human_email_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "lower(\"request_email_snapshot\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "join_requests_invite_id_invites_id_fk": {
+ "name": "join_requests_invite_id_invites_id_fk",
+ "tableFrom": "join_requests",
+ "tableTo": "invites",
+ "columnsFrom": [
+ "invite_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "join_requests_company_id_companies_id_fk": {
+ "name": "join_requests_company_id_companies_id_fk",
+ "tableFrom": "join_requests",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "join_requests_created_agent_id_agents_id_fk": {
+ "name": "join_requests_created_agent_id_agents_id_fk",
+ "tableFrom": "join_requests",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.labels": {
+ "name": "labels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "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": {
+ "labels_company_idx": {
+ "name": "labels_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "labels_company_name_idx": {
+ "name": "labels_company_name_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "labels_company_id_companies_id_fk": {
+ "name": "labels_company_id_companies_id_fk",
+ "tableFrom": "labels",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pipeline_automation_executions": {
+ "name": "pipeline_automation_executions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "automation_id": {
+ "name": "automation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "triggering_event_id": {
+ "name": "triggering_event_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_issue_id": {
+ "name": "execution_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retry_of_execution_id": {
+ "name": "retry_of_execution_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "generation": {
+ "name": "generation",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "error": {
+ "name": "error",
+ "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": {
+ "pipeline_automation_executions_idempotency_uq": {
+ "name": "pipeline_automation_executions_idempotency_uq",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "automation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "triggering_event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_automation_executions_company_case_idx": {
+ "name": "pipeline_automation_executions_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_automation_executions_routine_idx": {
+ "name": "pipeline_automation_executions_routine_idx",
+ "columns": [
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_automation_executions_execution_issue_idx": {
+ "name": "pipeline_automation_executions_execution_issue_idx",
+ "columns": [
+ {
+ "expression": "execution_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_automation_executions_retry_of_execution_idx": {
+ "name": "pipeline_automation_executions_retry_of_execution_idx",
+ "columns": [
+ {
+ "expression": "retry_of_execution_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_automation_executions_company_id_companies_id_fk": {
+ "name": "pipeline_automation_executions_company_id_companies_id_fk",
+ "tableFrom": "pipeline_automation_executions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_automation_executions_case_id_pipeline_cases_id_fk": {
+ "name": "pipeline_automation_executions_case_id_pipeline_cases_id_fk",
+ "tableFrom": "pipeline_automation_executions",
+ "tableTo": "pipeline_cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_automation_executions_routine_id_routines_id_fk": {
+ "name": "pipeline_automation_executions_routine_id_routines_id_fk",
+ "tableFrom": "pipeline_automation_executions",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_automation_executions_execution_issue_id_issues_id_fk": {
+ "name": "pipeline_automation_executions_execution_issue_id_issues_id_fk",
+ "tableFrom": "pipeline_automation_executions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "execution_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "pipeline_automation_executions_status_check": {
+ "name": "pipeline_automation_executions_status_check",
+ "value": "\"pipeline_automation_executions\".\"status\" in ('succeeded', 'failed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.pipeline_case_blockers": {
+ "name": "pipeline_case_blockers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_by_case_id": {
+ "name": "blocked_by_case_id",
+ "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": {
+ "pipeline_case_blockers_case_blocked_by_uq": {
+ "name": "pipeline_case_blockers_case_blocked_by_uq",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "blocked_by_case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_blockers_blocked_by_idx": {
+ "name": "pipeline_case_blockers_blocked_by_idx",
+ "columns": [
+ {
+ "expression": "blocked_by_case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_blockers_company_case_idx": {
+ "name": "pipeline_case_blockers_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_case_blockers_company_id_companies_id_fk": {
+ "name": "pipeline_case_blockers_company_id_companies_id_fk",
+ "tableFrom": "pipeline_case_blockers",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_blockers_case_id_pipeline_cases_id_fk": {
+ "name": "pipeline_case_blockers_case_id_pipeline_cases_id_fk",
+ "tableFrom": "pipeline_case_blockers",
+ "tableTo": "pipeline_cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk": {
+ "name": "pipeline_case_blockers_blocked_by_case_id_pipeline_cases_id_fk",
+ "tableFrom": "pipeline_case_blockers",
+ "tableTo": "pipeline_cases",
+ "columnsFrom": [
+ "blocked_by_case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "pipeline_case_blockers_no_self_block_check": {
+ "name": "pipeline_case_blockers_no_self_block_check",
+ "value": "\"pipeline_case_blockers\".\"case_id\" <> \"pipeline_case_blockers\".\"blocked_by_case_id\""
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.pipeline_case_documents": {
+ "name": "pipeline_case_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "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": {
+ "pipeline_case_documents_company_case_key_uq": {
+ "name": "pipeline_case_documents_company_case_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_documents_document_uq": {
+ "name": "pipeline_case_documents_document_uq",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_documents_company_case_updated_idx": {
+ "name": "pipeline_case_documents_company_case_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_case_documents_company_id_companies_id_fk": {
+ "name": "pipeline_case_documents_company_id_companies_id_fk",
+ "tableFrom": "pipeline_case_documents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_documents_case_id_pipeline_cases_id_fk": {
+ "name": "pipeline_case_documents_case_id_pipeline_cases_id_fk",
+ "tableFrom": "pipeline_case_documents",
+ "tableTo": "pipeline_cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_documents_document_id_documents_id_fk": {
+ "name": "pipeline_case_documents_document_id_documents_id_fk",
+ "tableFrom": "pipeline_case_documents",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pipeline_case_events": {
+ "name": "pipeline_case_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_agent_id": {
+ "name": "actor_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_stage_id": {
+ "name": "from_stage_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_stage_id": {
+ "name": "to_stage_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "pipeline_case_events_case_created_idx": {
+ "name": "pipeline_case_events_case_created_idx",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_events_company_case_idx": {
+ "name": "pipeline_case_events_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_case_events_company_id_companies_id_fk": {
+ "name": "pipeline_case_events_company_id_companies_id_fk",
+ "tableFrom": "pipeline_case_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_events_case_id_pipeline_cases_id_fk": {
+ "name": "pipeline_case_events_case_id_pipeline_cases_id_fk",
+ "tableFrom": "pipeline_case_events",
+ "tableTo": "pipeline_cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_events_actor_agent_id_agents_id_fk": {
+ "name": "pipeline_case_events_actor_agent_id_agents_id_fk",
+ "tableFrom": "pipeline_case_events",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "actor_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_events_from_stage_id_pipeline_stages_id_fk": {
+ "name": "pipeline_case_events_from_stage_id_pipeline_stages_id_fk",
+ "tableFrom": "pipeline_case_events",
+ "tableTo": "pipeline_stages",
+ "columnsFrom": [
+ "from_stage_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_events_to_stage_id_pipeline_stages_id_fk": {
+ "name": "pipeline_case_events_to_stage_id_pipeline_stages_id_fk",
+ "tableFrom": "pipeline_case_events",
+ "tableTo": "pipeline_stages",
+ "columnsFrom": [
+ "to_stage_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "pipeline_case_events_type_check": {
+ "name": "pipeline_case_events_type_check",
+ "value": "\"pipeline_case_events\".\"type\" in (\n 'ingested',\n 'updated',\n 'claimed',\n 'lease_released',\n 'lease_expired',\n 'transitioned',\n 'transition_forced',\n 'transition_suggested',\n 'suggestion_resolved',\n 'review_decided',\n 'conversation_opened',\n 'issue_linked',\n 'issue_unlinked',\n 'automation_executed',\n 'automation_failed',\n 'automation_retry_requested',\n 'automation_effects_retired',\n 'automation_retry_dispatched',\n 'blockers_set',\n 'blockers_resolved',\n 'children_terminal',\n 'upstream_drift',\n 'drift_acknowledged'\n )"
+ },
+ "pipeline_case_events_actor_type_check": {
+ "name": "pipeline_case_events_actor_type_check",
+ "value": "\"pipeline_case_events\".\"actor_type\" in ('user', 'agent', 'system')"
+ },
+ "pipeline_case_events_agent_run_check": {
+ "name": "pipeline_case_events_agent_run_check",
+ "value": "\"pipeline_case_events\".\"actor_type\" <> 'agent' or \"pipeline_case_events\".\"run_id\" is not null"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.pipeline_case_issue_links": {
+ "name": "pipeline_case_issue_links",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_id": {
+ "name": "case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "automation_attempt_id": {
+ "name": "automation_attempt_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retired_at": {
+ "name": "retired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retired_by_attempt_id": {
+ "name": "retired_by_attempt_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retired_reason": {
+ "name": "retired_reason",
+ "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": {
+ "pipeline_case_issue_links_case_issue_uq": {
+ "name": "pipeline_case_issue_links_case_issue_uq",
+ "columns": [
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_issue_links_issue_idx": {
+ "name": "pipeline_case_issue_links_issue_idx",
+ "columns": [
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_issue_links_company_case_idx": {
+ "name": "pipeline_case_issue_links_company_case_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_case_issue_links_automation_attempt_idx": {
+ "name": "pipeline_case_issue_links_automation_attempt_idx",
+ "columns": [
+ {
+ "expression": "automation_attempt_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_case_issue_links_company_id_companies_id_fk": {
+ "name": "pipeline_case_issue_links_company_id_companies_id_fk",
+ "tableFrom": "pipeline_case_issue_links",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_issue_links_case_id_pipeline_cases_id_fk": {
+ "name": "pipeline_case_issue_links_case_id_pipeline_cases_id_fk",
+ "tableFrom": "pipeline_case_issue_links",
+ "tableTo": "pipeline_cases",
+ "columnsFrom": [
+ "case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_case_issue_links_issue_id_issues_id_fk": {
+ "name": "pipeline_case_issue_links_issue_id_issues_id_fk",
+ "tableFrom": "pipeline_case_issue_links",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "pipeline_case_issue_links_role_check": {
+ "name": "pipeline_case_issue_links_role_check",
+ "value": "\"pipeline_case_issue_links\".\"role\" in ('origin', 'conversation', 'work', 'automation')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.pipeline_cases": {
+ "name": "pipeline_cases",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pipeline_id": {
+ "name": "pipeline_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "stage_id": {
+ "name": "stage_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "case_key": {
+ "name": "case_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fields": {
+ "name": "fields",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "workspace_ref": {
+ "name": "workspace_ref",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_case_id": {
+ "name": "parent_case_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_case_version": {
+ "name": "parent_case_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "request_key": {
+ "name": "request_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "automation_attempt_id": {
+ "name": "automation_attempt_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "pending_suggestion": {
+ "name": "pending_suggestion",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_owner_type": {
+ "name": "lease_owner_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_agent_id": {
+ "name": "lease_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_user_id": {
+ "name": "lease_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_token": {
+ "name": "lease_token",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_expires_at": {
+ "name": "lease_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "terminal_kind": {
+ "name": "terminal_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "terminal_at": {
+ "name": "terminal_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retired_at": {
+ "name": "retired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retired_by_attempt_id": {
+ "name": "retired_by_attempt_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "retired_reason": {
+ "name": "retired_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "hidden_from_board_at": {
+ "name": "hidden_from_board_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "child_count": {
+ "name": "child_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "terminal_child_count": {
+ "name": "terminal_child_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "origin_run_id": {
+ "name": "origin_run_id",
+ "type": "uuid",
+ "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": {
+ "pipeline_cases_pipeline_case_key_uq": {
+ "name": "pipeline_cases_pipeline_case_key_uq",
+ "columns": [
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "case_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_cases_parent_request_key_uq": {
+ "name": "pipeline_cases_parent_request_key_uq",
+ "columns": [
+ {
+ "expression": "parent_case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "request_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"pipeline_cases\".\"request_key\" is not null and \"pipeline_cases\".\"retired_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_cases_company_idx": {
+ "name": "pipeline_cases_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_cases_pipeline_stage_idx": {
+ "name": "pipeline_cases_pipeline_stage_idx",
+ "columns": [
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "stage_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_cases_parent_idx": {
+ "name": "pipeline_cases_parent_idx",
+ "columns": [
+ {
+ "expression": "parent_case_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_cases_automation_attempt_idx": {
+ "name": "pipeline_cases_automation_attempt_idx",
+ "columns": [
+ {
+ "expression": "automation_attempt_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_cases_retired_idx": {
+ "name": "pipeline_cases_retired_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "retired_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_cases_lease_expires_idx": {
+ "name": "pipeline_cases_lease_expires_idx",
+ "columns": [
+ {
+ "expression": "lease_expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"pipeline_cases\".\"lease_expires_at\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_cases_company_id_companies_id_fk": {
+ "name": "pipeline_cases_company_id_companies_id_fk",
+ "tableFrom": "pipeline_cases",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_cases_pipeline_id_pipelines_id_fk": {
+ "name": "pipeline_cases_pipeline_id_pipelines_id_fk",
+ "tableFrom": "pipeline_cases",
+ "tableTo": "pipelines",
+ "columnsFrom": [
+ "pipeline_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_cases_stage_id_pipeline_stages_id_fk": {
+ "name": "pipeline_cases_stage_id_pipeline_stages_id_fk",
+ "tableFrom": "pipeline_cases",
+ "tableTo": "pipeline_stages",
+ "columnsFrom": [
+ "stage_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "pipeline_cases_parent_case_id_pipeline_cases_id_fk": {
+ "name": "pipeline_cases_parent_case_id_pipeline_cases_id_fk",
+ "tableFrom": "pipeline_cases",
+ "tableTo": "pipeline_cases",
+ "columnsFrom": [
+ "parent_case_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "pipeline_cases_lease_agent_id_agents_id_fk": {
+ "name": "pipeline_cases_lease_agent_id_agents_id_fk",
+ "tableFrom": "pipeline_cases",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "lease_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "pipeline_cases_created_by_agent_id_agents_id_fk": {
+ "name": "pipeline_cases_created_by_agent_id_agents_id_fk",
+ "tableFrom": "pipeline_cases",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "pipeline_cases_terminal_kind_check": {
+ "name": "pipeline_cases_terminal_kind_check",
+ "value": "\"pipeline_cases\".\"terminal_kind\" is null or \"pipeline_cases\".\"terminal_kind\" in ('done', 'cancelled')"
+ },
+ "pipeline_cases_lease_owner_type_check": {
+ "name": "pipeline_cases_lease_owner_type_check",
+ "value": "\"pipeline_cases\".\"lease_owner_type\" is null or \"pipeline_cases\".\"lease_owner_type\" in ('user', 'agent')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.pipeline_documents": {
+ "name": "pipeline_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pipeline_id": {
+ "name": "pipeline_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "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": {
+ "pipeline_documents_company_pipeline_key_uq": {
+ "name": "pipeline_documents_company_pipeline_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_documents_document_uq": {
+ "name": "pipeline_documents_document_uq",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_documents_company_pipeline_updated_idx": {
+ "name": "pipeline_documents_company_pipeline_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_documents_company_id_companies_id_fk": {
+ "name": "pipeline_documents_company_id_companies_id_fk",
+ "tableFrom": "pipeline_documents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_documents_pipeline_id_pipelines_id_fk": {
+ "name": "pipeline_documents_pipeline_id_pipelines_id_fk",
+ "tableFrom": "pipeline_documents",
+ "tableTo": "pipelines",
+ "columnsFrom": [
+ "pipeline_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_documents_document_id_documents_id_fk": {
+ "name": "pipeline_documents_document_id_documents_id_fk",
+ "tableFrom": "pipeline_documents",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pipeline_stages": {
+ "name": "pipeline_stages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "pipeline_id": {
+ "name": "pipeline_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "pipeline_stages_pipeline_key_uq": {
+ "name": "pipeline_stages_pipeline_key_uq",
+ "columns": [
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_stages_pipeline_position_idx": {
+ "name": "pipeline_stages_pipeline_position_idx",
+ "columns": [
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_stages_pipeline_id_pipelines_id_fk": {
+ "name": "pipeline_stages_pipeline_id_pipelines_id_fk",
+ "tableFrom": "pipeline_stages",
+ "tableTo": "pipelines",
+ "columnsFrom": [
+ "pipeline_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "pipeline_stages_kind_check": {
+ "name": "pipeline_stages_kind_check",
+ "value": "\"pipeline_stages\".\"kind\" in ('working', 'review', 'done', 'cancelled')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.pipeline_transitions": {
+ "name": "pipeline_transitions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "pipeline_id": {
+ "name": "pipeline_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_stage_id": {
+ "name": "from_stage_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "to_stage_id": {
+ "name": "to_stage_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "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": {
+ "pipeline_transitions_pipeline_edge_uq": {
+ "name": "pipeline_transitions_pipeline_edge_uq",
+ "columns": [
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "from_stage_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "to_stage_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_transitions_pipeline_from_idx": {
+ "name": "pipeline_transitions_pipeline_from_idx",
+ "columns": [
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "from_stage_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipeline_transitions_pipeline_to_idx": {
+ "name": "pipeline_transitions_pipeline_to_idx",
+ "columns": [
+ {
+ "expression": "pipeline_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "to_stage_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipeline_transitions_pipeline_id_pipelines_id_fk": {
+ "name": "pipeline_transitions_pipeline_id_pipelines_id_fk",
+ "tableFrom": "pipeline_transitions",
+ "tableTo": "pipelines",
+ "columnsFrom": [
+ "pipeline_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_transitions_from_stage_id_pipeline_stages_id_fk": {
+ "name": "pipeline_transitions_from_stage_id_pipeline_stages_id_fk",
+ "tableFrom": "pipeline_transitions",
+ "tableTo": "pipeline_stages",
+ "columnsFrom": [
+ "from_stage_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipeline_transitions_to_stage_id_pipeline_stages_id_fk": {
+ "name": "pipeline_transitions_to_stage_id_pipeline_stages_id_fk",
+ "tableFrom": "pipeline_transitions",
+ "tableTo": "pipeline_stages",
+ "columnsFrom": [
+ "to_stage_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.pipelines": {
+ "name": "pipelines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enforce_transitions": {
+ "name": "enforce_transitions",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "pipelines_company_key_uq": {
+ "name": "pipelines_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipelines_company_idx": {
+ "name": "pipelines_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "pipelines_company_project_idx": {
+ "name": "pipelines_company_project_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "pipelines_company_id_companies_id_fk": {
+ "name": "pipelines_company_id_companies_id_fk",
+ "tableFrom": "pipelines",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "pipelines_project_id_projects_id_fk": {
+ "name": "pipelines_project_id_projects_id_fk",
+ "tableFrom": "pipelines",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "pipelines_created_by_agent_id_agents_id_fk": {
+ "name": "pipelines_created_by_agent_id_agents_id_fk",
+ "tableFrom": "pipelines",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_company_settings": {
+ "name": "plugin_company_settings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "settings_json": {
+ "name": "settings_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_company_settings_company_idx": {
+ "name": "plugin_company_settings_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_company_settings_plugin_idx": {
+ "name": "plugin_company_settings_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_company_settings_company_plugin_uq": {
+ "name": "plugin_company_settings_company_plugin_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_company_settings_company_id_companies_id_fk": {
+ "name": "plugin_company_settings_company_id_companies_id_fk",
+ "tableFrom": "plugin_company_settings",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_company_settings_plugin_id_plugins_id_fk": {
+ "name": "plugin_company_settings_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_company_settings",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_config": {
+ "name": "plugin_config",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_json": {
+ "name": "config_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_config_plugin_company_idx": {
+ "name": "plugin_config_plugin_company_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_config_plugin_id_plugins_id_fk": {
+ "name": "plugin_config_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_config",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_config_company_id_companies_id_fk": {
+ "name": "plugin_config_company_id_companies_id_fk",
+ "tableFrom": "plugin_config",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_database_namespaces": {
+ "name": "plugin_database_namespaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_key": {
+ "name": "plugin_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "namespace_name": {
+ "name": "namespace_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "namespace_mode": {
+ "name": "namespace_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'schema'"
+ },
+ "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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_database_namespaces_plugin_idx": {
+ "name": "plugin_database_namespaces_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_database_namespaces_namespace_idx": {
+ "name": "plugin_database_namespaces_namespace_idx",
+ "columns": [
+ {
+ "expression": "namespace_name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_database_namespaces_status_idx": {
+ "name": "plugin_database_namespaces_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_database_namespaces_plugin_id_plugins_id_fk": {
+ "name": "plugin_database_namespaces_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_database_namespaces",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_entities": {
+ "name": "plugin_entities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "external_id": {
+ "name": "external_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "data": {
+ "name": "data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "plugin_entities_plugin_idx": {
+ "name": "plugin_entities_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_entities_company_idx": {
+ "name": "plugin_entities_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_entities_type_idx": {
+ "name": "plugin_entities_type_idx",
+ "columns": [
+ {
+ "expression": "entity_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_entities_scope_idx": {
+ "name": "plugin_entities_scope_idx",
+ "columns": [
+ {
+ "expression": "scope_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_entities_plugin_id_plugins_id_fk": {
+ "name": "plugin_entities_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_entities",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_entities_company_id_companies_id_fk": {
+ "name": "plugin_entities_company_id_companies_id_fk",
+ "tableFrom": "plugin_entities",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "plugin_entities_external_idx": {
+ "name": "plugin_entities_external_idx",
+ "nullsNotDistinct": true,
+ "columns": [
+ "company_id",
+ "plugin_id",
+ "entity_type",
+ "external_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_job_runs": {
+ "name": "plugin_job_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logs": {
+ "name": "logs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "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_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_job_runs_job_idx": {
+ "name": "plugin_job_runs_job_idx",
+ "columns": [
+ {
+ "expression": "job_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_job_runs_plugin_idx": {
+ "name": "plugin_job_runs_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_job_runs_company_idx": {
+ "name": "plugin_job_runs_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_job_runs_status_idx": {
+ "name": "plugin_job_runs_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_job_runs_job_id_plugin_jobs_id_fk": {
+ "name": "plugin_job_runs_job_id_plugin_jobs_id_fk",
+ "tableFrom": "plugin_job_runs",
+ "tableTo": "plugin_jobs",
+ "columnsFrom": [
+ "job_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_job_runs_plugin_id_plugins_id_fk": {
+ "name": "plugin_job_runs_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_job_runs",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_job_runs_company_id_companies_id_fk": {
+ "name": "plugin_job_runs_company_id_companies_id_fk",
+ "tableFrom": "plugin_job_runs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_jobs": {
+ "name": "plugin_jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "job_key": {
+ "name": "job_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schedule": {
+ "name": "schedule",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_run_at": {
+ "name": "next_run_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_jobs_plugin_idx": {
+ "name": "plugin_jobs_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_jobs_next_run_idx": {
+ "name": "plugin_jobs_next_run_idx",
+ "columns": [
+ {
+ "expression": "next_run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_jobs_unique_idx": {
+ "name": "plugin_jobs_unique_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "job_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_jobs_plugin_id_plugins_id_fk": {
+ "name": "plugin_jobs_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_jobs",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_logs": {
+ "name": "plugin_logs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "level": {
+ "name": "level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'info'"
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "meta": {
+ "name": "meta",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_logs_plugin_time_idx": {
+ "name": "plugin_logs_plugin_time_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_logs_company_idx": {
+ "name": "plugin_logs_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_logs_level_idx": {
+ "name": "plugin_logs_level_idx",
+ "columns": [
+ {
+ "expression": "level",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_logs_plugin_id_plugins_id_fk": {
+ "name": "plugin_logs_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_logs",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_logs_company_id_companies_id_fk": {
+ "name": "plugin_logs_company_id_companies_id_fk",
+ "tableFrom": "plugin_logs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_managed_resources": {
+ "name": "plugin_managed_resources",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_key": {
+ "name": "plugin_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_kind": {
+ "name": "resource_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_key": {
+ "name": "resource_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "resource_id": {
+ "name": "resource_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "defaults_json": {
+ "name": "defaults_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "plugin_managed_resources_company_idx": {
+ "name": "plugin_managed_resources_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_managed_resources_plugin_idx": {
+ "name": "plugin_managed_resources_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_managed_resources_resource_idx": {
+ "name": "plugin_managed_resources_resource_idx",
+ "columns": [
+ {
+ "expression": "resource_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_managed_resources_company_plugin_resource_uq": {
+ "name": "plugin_managed_resources_company_plugin_resource_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "resource_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_managed_resources_company_id_companies_id_fk": {
+ "name": "plugin_managed_resources_company_id_companies_id_fk",
+ "tableFrom": "plugin_managed_resources",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_managed_resources_plugin_id_plugins_id_fk": {
+ "name": "plugin_managed_resources_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_managed_resources",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_migrations": {
+ "name": "plugin_migrations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_key": {
+ "name": "plugin_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "namespace_name": {
+ "name": "namespace_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "migration_key": {
+ "name": "migration_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "plugin_version": {
+ "name": "plugin_version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "applied_at": {
+ "name": "applied_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "plugin_migrations_plugin_key_idx": {
+ "name": "plugin_migrations_plugin_key_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "migration_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_migrations_plugin_idx": {
+ "name": "plugin_migrations_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_migrations_status_idx": {
+ "name": "plugin_migrations_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_migrations_plugin_id_plugins_id_fk": {
+ "name": "plugin_migrations_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_migrations",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_state": {
+ "name": "plugin_state",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "namespace": {
+ "name": "namespace",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "state_key": {
+ "name": "state_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value_json": {
+ "name": "value_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_state_plugin_scope_idx": {
+ "name": "plugin_state_plugin_scope_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_state_plugin_id_plugins_id_fk": {
+ "name": "plugin_state_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_state",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "plugin_state_unique_entry_idx": {
+ "name": "plugin_state_unique_entry_idx",
+ "nullsNotDistinct": true,
+ "columns": [
+ "plugin_id",
+ "scope_kind",
+ "scope_id",
+ "namespace",
+ "state_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_webhook_deliveries": {
+ "name": "plugin_webhook_deliveries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "webhook_key": {
+ "name": "webhook_key",
+ "type": "text",
+ "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'"
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "headers": {
+ "name": "headers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_webhook_deliveries_plugin_idx": {
+ "name": "plugin_webhook_deliveries_plugin_idx",
+ "columns": [
+ {
+ "expression": "plugin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_webhook_deliveries_company_idx": {
+ "name": "plugin_webhook_deliveries_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_webhook_deliveries_status_idx": {
+ "name": "plugin_webhook_deliveries_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugin_webhook_deliveries_key_idx": {
+ "name": "plugin_webhook_deliveries_key_idx",
+ "columns": [
+ {
+ "expression": "webhook_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_webhook_deliveries_plugin_id_plugins_id_fk": {
+ "name": "plugin_webhook_deliveries_plugin_id_plugins_id_fk",
+ "tableFrom": "plugin_webhook_deliveries",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "plugin_webhook_deliveries_company_id_companies_id_fk": {
+ "name": "plugin_webhook_deliveries_company_id_companies_id_fk",
+ "tableFrom": "plugin_webhook_deliveries",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugins": {
+ "name": "plugins",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "plugin_key": {
+ "name": "plugin_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "package_name": {
+ "name": "package_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "api_version": {
+ "name": "api_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "categories": {
+ "name": "categories",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "manifest_json": {
+ "name": "manifest_json",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'installed'"
+ },
+ "install_order": {
+ "name": "install_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "package_path": {
+ "name": "package_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "installed_at": {
+ "name": "installed_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": {
+ "plugins_plugin_key_idx": {
+ "name": "plugins_plugin_key_idx",
+ "columns": [
+ {
+ "expression": "plugin_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "plugins_status_idx": {
+ "name": "plugins_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.principal_permission_grants": {
+ "name": "principal_permission_grants",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal_type": {
+ "name": "principal_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "principal_id": {
+ "name": "principal_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission_key": {
+ "name": "permission_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "granted_by_user_id": {
+ "name": "granted_by_user_id",
+ "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": {
+ "principal_permission_grants_unique_idx": {
+ "name": "principal_permission_grants_unique_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "principal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "permission_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "principal_permission_grants_company_permission_idx": {
+ "name": "principal_permission_grants_company_permission_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "permission_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "principal_permission_grants_company_id_companies_id_fk": {
+ "name": "principal_permission_grants_company_id_companies_id_fk",
+ "tableFrom": "principal_permission_grants",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_goals": {
+ "name": "project_goals",
+ "schema": "",
+ "columns": {
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "goal_id": {
+ "name": "goal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "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": {
+ "project_goals_project_idx": {
+ "name": "project_goals_project_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_goals_goal_idx": {
+ "name": "project_goals_goal_idx",
+ "columns": [
+ {
+ "expression": "goal_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_goals_company_idx": {
+ "name": "project_goals_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "project_goals_project_id_projects_id_fk": {
+ "name": "project_goals_project_id_projects_id_fk",
+ "tableFrom": "project_goals",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_goals_goal_id_goals_id_fk": {
+ "name": "project_goals_goal_id_goals_id_fk",
+ "tableFrom": "project_goals",
+ "tableTo": "goals",
+ "columnsFrom": [
+ "goal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_goals_company_id_companies_id_fk": {
+ "name": "project_goals_company_id_companies_id_fk",
+ "tableFrom": "project_goals",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "project_goals_project_id_goal_id_pk": {
+ "name": "project_goals_project_id_goal_id_pk",
+ "columns": [
+ "project_id",
+ "goal_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_memberships": {
+ "name": "project_memberships",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'joined'"
+ },
+ "starred_at": {
+ "name": "starred_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "project_memberships_company_user_idx": {
+ "name": "project_memberships_company_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_memberships_company_user_starred_idx": {
+ "name": "project_memberships_company_user_starred_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "starred_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_memberships_project_idx": {
+ "name": "project_memberships_project_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_memberships_company_user_project_uq": {
+ "name": "project_memberships_company_user_project_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "project_memberships_company_id_companies_id_fk": {
+ "name": "project_memberships_company_id_companies_id_fk",
+ "tableFrom": "project_memberships",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "project_memberships_project_id_projects_id_fk": {
+ "name": "project_memberships_project_id_projects_id_fk",
+ "tableFrom": "project_memberships",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.project_workspaces": {
+ "name": "project_workspaces",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_type": {
+ "name": "source_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local_path'"
+ },
+ "cwd": {
+ "name": "cwd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "repo_url": {
+ "name": "repo_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "repo_ref": {
+ "name": "repo_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_ref": {
+ "name": "default_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "setup_command": {
+ "name": "setup_command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cleanup_command": {
+ "name": "cleanup_command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remote_provider": {
+ "name": "remote_provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "remote_workspace_ref": {
+ "name": "remote_workspace_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "shared_workspace_key": {
+ "name": "shared_workspace_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_primary": {
+ "name": "is_primary",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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": {
+ "project_workspaces_company_project_idx": {
+ "name": "project_workspaces_company_project_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_workspaces_project_primary_idx": {
+ "name": "project_workspaces_project_primary_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "is_primary",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_workspaces_project_source_type_idx": {
+ "name": "project_workspaces_project_source_type_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "source_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_workspaces_company_shared_key_idx": {
+ "name": "project_workspaces_company_shared_key_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "shared_workspace_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "project_workspaces_project_remote_ref_idx": {
+ "name": "project_workspaces_project_remote_ref_idx",
+ "columns": [
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_workspace_ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "project_workspaces_company_id_companies_id_fk": {
+ "name": "project_workspaces_company_id_companies_id_fk",
+ "tableFrom": "project_workspaces",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "project_workspaces_project_id_projects_id_fk": {
+ "name": "project_workspaces_project_id_projects_id_fk",
+ "tableFrom": "project_workspaces",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.projects": {
+ "name": "projects",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "goal_id": {
+ "name": "goal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": "'backlog'"
+ },
+ "lead_agent_id": {
+ "name": "lead_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target_date": {
+ "name": "target_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "env": {
+ "name": "env",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "pause_reason": {
+ "name": "pause_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "paused_at": {
+ "name": "paused_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_workspace_policy": {
+ "name": "execution_workspace_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "projects_company_idx": {
+ "name": "projects_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "projects_company_id_companies_id_fk": {
+ "name": "projects_company_id_companies_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "projects_goal_id_goals_id_fk": {
+ "name": "projects_goal_id_goals_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "goals",
+ "columnsFrom": [
+ "goal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "projects_lead_agent_id_agents_id_fk": {
+ "name": "projects_lead_agent_id_agents_id_fk",
+ "tableFrom": "projects",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "lead_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_documents": {
+ "name": "routine_documents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "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": {
+ "routine_documents_company_routine_key_uq": {
+ "name": "routine_documents_company_routine_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_documents_document_uq": {
+ "name": "routine_documents_document_uq",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_documents_company_routine_updated_idx": {
+ "name": "routine_documents_company_routine_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routine_documents_company_id_companies_id_fk": {
+ "name": "routine_documents_company_id_companies_id_fk",
+ "tableFrom": "routine_documents",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "routine_documents_routine_id_routines_id_fk": {
+ "name": "routine_documents_routine_id_routines_id_fk",
+ "tableFrom": "routine_documents",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routine_documents_document_id_documents_id_fk": {
+ "name": "routine_documents_document_id_documents_id_fk",
+ "tableFrom": "routine_documents",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_revisions": {
+ "name": "routine_revisions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revision_number": {
+ "name": "revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "snapshot": {
+ "name": "snapshot",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "change_summary": {
+ "name": "change_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "restored_from_revision_id": {
+ "name": "restored_from_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_run_id": {
+ "name": "created_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "routine_revisions_routine_revision_uq": {
+ "name": "routine_revisions_routine_revision_uq",
+ "columns": [
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "revision_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_revisions_company_routine_created_idx": {
+ "name": "routine_revisions_company_routine_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_revisions_company_responsible_user_idx": {
+ "name": "routine_revisions_company_responsible_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "responsible_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routine_revisions_company_id_companies_id_fk": {
+ "name": "routine_revisions_company_id_companies_id_fk",
+ "tableFrom": "routine_revisions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routine_revisions_routine_id_routines_id_fk": {
+ "name": "routine_revisions_routine_id_routines_id_fk",
+ "tableFrom": "routine_revisions",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routine_revisions_restored_from_revision_id_routine_revisions_id_fk": {
+ "name": "routine_revisions_restored_from_revision_id_routine_revisions_id_fk",
+ "tableFrom": "routine_revisions",
+ "tableTo": "routine_revisions",
+ "columnsFrom": [
+ "restored_from_revision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routine_revisions_created_by_agent_id_agents_id_fk": {
+ "name": "routine_revisions_created_by_agent_id_agents_id_fk",
+ "tableFrom": "routine_revisions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routine_revisions_created_by_run_id_heartbeat_runs_id_fk": {
+ "name": "routine_revisions_created_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "routine_revisions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "created_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_runs": {
+ "name": "routine_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trigger_id": {
+ "name": "trigger_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'received'"
+ },
+ "triggered_at": {
+ "name": "triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "routine_revision_id": {
+ "name": "routine_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trigger_payload": {
+ "name": "trigger_payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dispatch_fingerprint": {
+ "name": "dispatch_fingerprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "linked_issue_id": {
+ "name": "linked_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "coalesced_into_run_id": {
+ "name": "coalesced_into_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "routine_runs_company_routine_idx": {
+ "name": "routine_runs_company_routine_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_runs_revision_idx": {
+ "name": "routine_runs_revision_idx",
+ "columns": [
+ {
+ "expression": "routine_revision_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_runs_company_responsible_user_idx": {
+ "name": "routine_runs_company_responsible_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "responsible_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_runs_trigger_idx": {
+ "name": "routine_runs_trigger_idx",
+ "columns": [
+ {
+ "expression": "trigger_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_runs_dispatch_fingerprint_idx": {
+ "name": "routine_runs_dispatch_fingerprint_idx",
+ "columns": [
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "dispatch_fingerprint",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_runs_linked_issue_idx": {
+ "name": "routine_runs_linked_issue_idx",
+ "columns": [
+ {
+ "expression": "linked_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_runs_trigger_idempotency_idx": {
+ "name": "routine_runs_trigger_idempotency_idx",
+ "columns": [
+ {
+ "expression": "trigger_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routine_runs_company_id_companies_id_fk": {
+ "name": "routine_runs_company_id_companies_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routine_runs_routine_id_routines_id_fk": {
+ "name": "routine_runs_routine_id_routines_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routine_runs_trigger_id_routine_triggers_id_fk": {
+ "name": "routine_runs_trigger_id_routine_triggers_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "routine_triggers",
+ "columnsFrom": [
+ "trigger_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routine_runs_routine_revision_id_routine_revisions_id_fk": {
+ "name": "routine_runs_routine_revision_id_routine_revisions_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "routine_revisions",
+ "columnsFrom": [
+ "routine_revision_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routine_runs_linked_issue_id_issues_id_fk": {
+ "name": "routine_runs_linked_issue_id_issues_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "linked_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_triggers": {
+ "name": "routine_triggers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_run_at": {
+ "name": "next_run_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
+ },
+ "public_id": {
+ "name": "public_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secret_id": {
+ "name": "secret_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "signing_mode": {
+ "name": "signing_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "replay_window_sec": {
+ "name": "replay_window_sec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_rotated_at": {
+ "name": "last_rotated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_result": {
+ "name": "last_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_agent_id": {
+ "name": "updated_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "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": {
+ "routine_triggers_company_routine_idx": {
+ "name": "routine_triggers_company_routine_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_triggers_company_kind_idx": {
+ "name": "routine_triggers_company_kind_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_triggers_next_run_idx": {
+ "name": "routine_triggers_next_run_idx",
+ "columns": [
+ {
+ "expression": "next_run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_triggers_public_id_idx": {
+ "name": "routine_triggers_public_id_idx",
+ "columns": [
+ {
+ "expression": "public_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routine_triggers_public_id_uq": {
+ "name": "routine_triggers_public_id_uq",
+ "columns": [
+ {
+ "expression": "public_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routine_triggers_company_id_companies_id_fk": {
+ "name": "routine_triggers_company_id_companies_id_fk",
+ "tableFrom": "routine_triggers",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routine_triggers_routine_id_routines_id_fk": {
+ "name": "routine_triggers_routine_id_routines_id_fk",
+ "tableFrom": "routine_triggers",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routine_triggers_secret_id_company_secrets_id_fk": {
+ "name": "routine_triggers_secret_id_company_secrets_id_fk",
+ "tableFrom": "routine_triggers",
+ "tableTo": "company_secrets",
+ "columnsFrom": [
+ "secret_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routine_triggers_created_by_agent_id_agents_id_fk": {
+ "name": "routine_triggers_created_by_agent_id_agents_id_fk",
+ "tableFrom": "routine_triggers",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routine_triggers_updated_by_agent_id_agents_id_fk": {
+ "name": "routine_triggers_updated_by_agent_id_agents_id_fk",
+ "tableFrom": "routine_triggers",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "updated_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routines": {
+ "name": "routines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "folder_id": {
+ "name": "folder_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "goal_id": {
+ "name": "goal_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "parent_issue_id": {
+ "name": "parent_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "assignee_agent_id": {
+ "name": "assignee_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "priority": {
+ "name": "priority",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'medium'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "concurrency_policy": {
+ "name": "concurrency_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'coalesce_if_active'"
+ },
+ "catch_up_policy": {
+ "name": "catch_up_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'skip_missed'"
+ },
+ "activity_gate_policy": {
+ "name": "activity_gate_policy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'always'"
+ },
+ "activity_gate_scope": {
+ "name": "activity_gate_scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'company'"
+ },
+ "origin_kind": {
+ "name": "origin_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'manual'"
+ },
+ "origin_id": {
+ "name": "origin_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "variables": {
+ "name": "variables",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "env": {
+ "name": "env",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latest_revision_id": {
+ "name": "latest_revision_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latest_revision_number": {
+ "name": "latest_revision_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_agent_id": {
+ "name": "updated_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_triggered_at": {
+ "name": "last_triggered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_enqueued_at": {
+ "name": "last_enqueued_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "routines_company_status_idx": {
+ "name": "routines_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_company_assignee_idx": {
+ "name": "routines_company_assignee_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "assignee_agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_company_project_idx": {
+ "name": "routines_company_project_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_company_folder_idx": {
+ "name": "routines_company_folder_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "folder_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_company_responsible_user_idx": {
+ "name": "routines_company_responsible_user_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "responsible_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_company_origin_idx": {
+ "name": "routines_company_origin_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "origin_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routines_company_id_companies_id_fk": {
+ "name": "routines_company_id_companies_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routines_project_id_projects_id_fk": {
+ "name": "routines_project_id_projects_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routines_folder_id_folders_id_fk": {
+ "name": "routines_folder_id_folders_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "folders",
+ "columnsFrom": [
+ "folder_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routines_goal_id_goals_id_fk": {
+ "name": "routines_goal_id_goals_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "goals",
+ "columnsFrom": [
+ "goal_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routines_parent_issue_id_issues_id_fk": {
+ "name": "routines_parent_issue_id_issues_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "parent_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routines_assignee_agent_id_agents_id_fk": {
+ "name": "routines_assignee_agent_id_agents_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "assignee_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "routines_created_by_agent_id_agents_id_fk": {
+ "name": "routines_created_by_agent_id_agents_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "routines_updated_by_agent_id_agents_id_fk": {
+ "name": "routines_updated_by_agent_id_agents_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "updated_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.secret_access_events": {
+ "name": "secret_access_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_id": {
+ "name": "secret_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_secret_definition_id": {
+ "name": "user_secret_definition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "secret_scope": {
+ "name": "secret_scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'company'"
+ },
+ "version": {
+ "name": "version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "responsible_user_id": {
+ "name": "responsible_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_owner_user_id": {
+ "name": "credential_owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_subject_type": {
+ "name": "credential_subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_subject_id": {
+ "name": "credential_subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "consumer_type": {
+ "name": "consumer_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "consumer_id": {
+ "name": "consumer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_path": {
+ "name": "config_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heartbeat_run_id": {
+ "name": "heartbeat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "secret_access_events_company_created_idx": {
+ "name": "secret_access_events_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "secret_access_events_secret_created_idx": {
+ "name": "secret_access_events_secret_created_idx",
+ "columns": [
+ {
+ "expression": "secret_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "secret_access_events_user_definition_created_idx": {
+ "name": "secret_access_events_user_definition_created_idx",
+ "columns": [
+ {
+ "expression": "user_secret_definition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "secret_access_events_company_credential_owner_idx": {
+ "name": "secret_access_events_company_credential_owner_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "credential_owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "secret_access_events_consumer_idx": {
+ "name": "secret_access_events_consumer_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "consumer_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "consumer_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "secret_access_events_run_idx": {
+ "name": "secret_access_events_run_idx",
+ "columns": [
+ {
+ "expression": "heartbeat_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "secret_access_events_company_id_companies_id_fk": {
+ "name": "secret_access_events_company_id_companies_id_fk",
+ "tableFrom": "secret_access_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "secret_access_events_secret_id_company_secrets_id_fk": {
+ "name": "secret_access_events_secret_id_company_secrets_id_fk",
+ "tableFrom": "secret_access_events",
+ "tableTo": "company_secrets",
+ "columnsFrom": [
+ "secret_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk": {
+ "name": "secret_access_events_user_secret_definition_id_user_secret_definitions_id_fk",
+ "tableFrom": "secret_access_events",
+ "tableTo": "user_secret_definitions",
+ "columnsFrom": [
+ "user_secret_definition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "secret_access_events_issue_id_issues_id_fk": {
+ "name": "secret_access_events_issue_id_issues_id_fk",
+ "tableFrom": "secret_access_events",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk": {
+ "name": "secret_access_events_heartbeat_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "secret_access_events",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "heartbeat_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "secret_access_events_plugin_id_plugins_id_fk": {
+ "name": "secret_access_events_plugin_id_plugins_id_fk",
+ "tableFrom": "secret_access_events",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.smoke_run_steps": {
+ "name": "smoke_run_steps",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "path": {
+ "name": "path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scenario_step": {
+ "name": "scenario_step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "detail": {
+ "name": "detail",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "screenshot_artifact_ref": {
+ "name": "screenshot_artifact_ref",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "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": {
+ "smoke_run_steps_company_run_idx": {
+ "name": "smoke_run_steps_company_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "smoke_run_steps_company_path_idx": {
+ "name": "smoke_run_steps_company_path_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "smoke_run_steps_company_id_companies_id_fk": {
+ "name": "smoke_run_steps_company_id_companies_id_fk",
+ "tableFrom": "smoke_run_steps",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "smoke_run_steps_run_id_smoke_runs_id_fk": {
+ "name": "smoke_run_steps_run_id_smoke_runs_id_fk",
+ "tableFrom": "smoke_run_steps",
+ "tableTo": "smoke_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.smoke_runs": {
+ "name": "smoke_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'running'"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "smoke_runs_company_started_idx": {
+ "name": "smoke_runs_company_started_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "smoke_runs_company_status_idx": {
+ "name": "smoke_runs_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "smoke_runs_company_id_companies_id_fk": {
+ "name": "smoke_runs_company_id_companies_id_fk",
+ "tableFrom": "smoke_runs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.status_card_updates": {
+ "name": "status_card_updates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "card_id": {
+ "name": "card_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "generation_issue_id": {
+ "name": "generation_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "changes": {
+ "name": "changes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cost_cents": {
+ "name": "cost_cents",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "query_version": {
+ "name": "query_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "change_summary": {
+ "name": "change_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "status_card_updates_card_started_idx": {
+ "name": "status_card_updates_card_started_idx",
+ "columns": [
+ {
+ "expression": "card_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "status_card_updates_generation_issue_idx": {
+ "name": "status_card_updates_generation_issue_idx",
+ "columns": [
+ {
+ "expression": "generation_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "status_card_updates_card_id_status_cards_id_fk": {
+ "name": "status_card_updates_card_id_status_cards_id_fk",
+ "tableFrom": "status_card_updates",
+ "tableTo": "status_cards",
+ "columnsFrom": [
+ "card_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "status_card_updates_generation_issue_id_issues_id_fk": {
+ "name": "status_card_updates_generation_issue_id_issues_id_fk",
+ "tableFrom": "status_card_updates",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "generation_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "status_card_updates_run_id_heartbeat_runs_id_fk": {
+ "name": "status_card_updates_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "status_card_updates",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.status_cards": {
+ "name": "status_cards",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title_pinned": {
+ "name": "title_pinned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "interest_prompt": {
+ "name": "interest_prompt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "queries": {
+ "name": "queries",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "query_version": {
+ "name": "query_version",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "query_compiled_at": {
+ "name": "query_compiled_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "query_compiled_by_agent_id": {
+ "name": "query_compiled_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_policy": {
+ "name": "refresh_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'compiling'"
+ },
+ "pending_change_count": {
+ "name": "pending_change_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "pending_change_hash": {
+ "name": "pending_change_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_change_at": {
+ "name": "last_change_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fingerprint_at": {
+ "name": "fingerprint_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mentioned_issue_ids": {
+ "name": "mentioned_issue_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_update_run_kind": {
+ "name": "last_update_run_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_generated_at": {
+ "name": "last_generated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_model": {
+ "name": "last_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "generating_issue_id": {
+ "name": "generating_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_eval_at": {
+ "name": "next_eval_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_by_user_id": {
+ "name": "archived_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_by_agent_id": {
+ "name": "archived_by_agent_id",
+ "type": "uuid",
+ "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": {
+ "status_cards_company_archived_idx": {
+ "name": "status_cards_company_archived_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "archived_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "status_cards_company_next_eval_idx": {
+ "name": "status_cards_company_next_eval_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_eval_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "status_cards_company_id_companies_id_fk": {
+ "name": "status_cards_company_id_companies_id_fk",
+ "tableFrom": "status_cards",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "status_cards_created_by_agent_id_agents_id_fk": {
+ "name": "status_cards_created_by_agent_id_agents_id_fk",
+ "tableFrom": "status_cards",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "status_cards_query_compiled_by_agent_id_agents_id_fk": {
+ "name": "status_cards_query_compiled_by_agent_id_agents_id_fk",
+ "tableFrom": "status_cards",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "query_compiled_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "status_cards_agent_id_agents_id_fk": {
+ "name": "status_cards_agent_id_agents_id_fk",
+ "tableFrom": "status_cards",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "status_cards_document_id_documents_id_fk": {
+ "name": "status_cards_document_id_documents_id_fk",
+ "tableFrom": "status_cards",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "status_cards_generating_issue_id_issues_id_fk": {
+ "name": "status_cards_generating_issue_id_issues_id_fk",
+ "tableFrom": "status_cards",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "generating_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "status_cards_archived_by_agent_id_agents_id_fk": {
+ "name": "status_cards_archived_by_agent_id_agents_id_fk",
+ "tableFrom": "status_cards",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "archived_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.summary_slots": {
+ "name": "summary_slots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_kind": {
+ "name": "scope_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slot_key": {
+ "name": "slot_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "document_id": {
+ "name": "document_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "generating_issue_id": {
+ "name": "generating_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_generated_at": {
+ "name": "last_generated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_generated_by_agent_id": {
+ "name": "last_generated_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_model": {
+ "name": "last_model",
+ "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": {
+ "summary_slots_document_uq": {
+ "name": "summary_slots_document_uq",
+ "columns": [
+ {
+ "expression": "document_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "summary_slots_company_scope_idx": {
+ "name": "summary_slots_company_scope_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "scope_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "summary_slots_company_generating_issue_idx": {
+ "name": "summary_slots_company_generating_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "generating_issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "summary_slots_company_updated_idx": {
+ "name": "summary_slots_company_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "summary_slots_company_id_companies_id_fk": {
+ "name": "summary_slots_company_id_companies_id_fk",
+ "tableFrom": "summary_slots",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "summary_slots_document_id_documents_id_fk": {
+ "name": "summary_slots_document_id_documents_id_fk",
+ "tableFrom": "summary_slots",
+ "tableTo": "documents",
+ "columnsFrom": [
+ "document_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "summary_slots_generating_issue_id_issues_id_fk": {
+ "name": "summary_slots_generating_issue_id_issues_id_fk",
+ "tableFrom": "summary_slots",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "generating_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "summary_slots_last_generated_by_agent_id_agents_id_fk": {
+ "name": "summary_slots_last_generated_by_agent_id_agents_id_fk",
+ "tableFrom": "summary_slots",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "last_generated_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "summary_slots_company_scope_slot_uq": {
+ "name": "summary_slots_company_scope_slot_uq",
+ "nullsNotDistinct": true,
+ "columns": [
+ "company_id",
+ "scope_kind",
+ "scope_id",
+ "slot_key"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_access_audit_events": {
+ "name": "tool_access_audit_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "gateway_id": {
+ "name": "gateway_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_token_id": {
+ "name": "gateway_token_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_public_id": {
+ "name": "gateway_public_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "correlation_id": {
+ "name": "correlation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "catalog_entry_id": {
+ "name": "catalog_entry_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason_code": {
+ "name": "reason_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "details": {
+ "name": "details",
+ "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": {
+ "tool_access_audit_company_created_idx": {
+ "name": "tool_access_audit_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_access_audit_connection_idx": {
+ "name": "tool_access_audit_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_access_audit_gateway_idx": {
+ "name": "tool_access_audit_gateway_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "gateway_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_access_audit_events_company_id_companies_id_fk": {
+ "name": "tool_access_audit_events_company_id_companies_id_fk",
+ "tableFrom": "tool_access_audit_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk": {
+ "name": "tool_access_audit_events_gateway_id_tool_mcp_gateways_id_fk",
+ "tableFrom": "tool_access_audit_events",
+ "tableTo": "tool_mcp_gateways",
+ "columnsFrom": [
+ "gateway_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": {
+ "name": "tool_access_audit_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk",
+ "tableFrom": "tool_access_audit_events",
+ "tableTo": "tool_mcp_gateway_tokens",
+ "columnsFrom": [
+ "gateway_token_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_access_audit_events_connection_id_tool_connections_id_fk": {
+ "name": "tool_access_audit_events_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_access_audit_events",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk": {
+ "name": "tool_access_audit_events_catalog_entry_id_tool_catalog_entries_id_fk",
+ "tableFrom": "tool_access_audit_events",
+ "tableTo": "tool_catalog_entries",
+ "columnsFrom": [
+ "catalog_entry_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_action_requests": {
+ "name": "tool_action_requests",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invocation_id": {
+ "name": "invocation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interaction_id": {
+ "name": "interaction_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approval_id": {
+ "name": "approval_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "canonical_arguments_hash": {
+ "name": "canonical_arguments_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "canonical_arguments_summary": {
+ "name": "canonical_arguments_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "signed_arguments": {
+ "name": "signed_arguments",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "preview_markdown": {
+ "name": "preview_markdown",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_by_agent_id": {
+ "name": "requested_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_by_user_id": {
+ "name": "requested_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_agent_id": {
+ "name": "resolved_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_by_user_id": {
+ "name": "resolved_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_agent_id": {
+ "name": "decided_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_by_user_id": {
+ "name": "decided_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "resolved_at": {
+ "name": "resolved_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_action_requests_company_status_idx": {
+ "name": "tool_action_requests_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_action_requests_invocation_idx": {
+ "name": "tool_action_requests_invocation_idx",
+ "columns": [
+ {
+ "expression": "invocation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_action_requests_issue_idx": {
+ "name": "tool_action_requests_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_action_requests_company_id_companies_id_fk": {
+ "name": "tool_action_requests_company_id_companies_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_action_requests_invocation_id_tool_invocations_id_fk": {
+ "name": "tool_action_requests_invocation_id_tool_invocations_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "tool_invocations",
+ "columnsFrom": [
+ "invocation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_action_requests_issue_id_issues_id_fk": {
+ "name": "tool_action_requests_issue_id_issues_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_action_requests_interaction_id_issue_thread_interactions_id_fk": {
+ "name": "tool_action_requests_interaction_id_issue_thread_interactions_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "issue_thread_interactions",
+ "columnsFrom": [
+ "interaction_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_action_requests_approval_id_approvals_id_fk": {
+ "name": "tool_action_requests_approval_id_approvals_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "approvals",
+ "columnsFrom": [
+ "approval_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_action_requests_requested_by_agent_id_agents_id_fk": {
+ "name": "tool_action_requests_requested_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "requested_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_action_requests_resolved_by_agent_id_agents_id_fk": {
+ "name": "tool_action_requests_resolved_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "resolved_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_action_requests_decided_by_agent_id_agents_id_fk": {
+ "name": "tool_action_requests_decided_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_action_requests",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "decided_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_applications": {
+ "name": "tool_applications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_key": {
+ "name": "application_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "plugin_id": {
+ "name": "plugin_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_agent_id": {
+ "name": "owner_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "archived_at": {
+ "name": "archived_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_applications_company_idx": {
+ "name": "tool_applications_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_applications_company_status_idx": {
+ "name": "tool_applications_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_applications_company_name_uq": {
+ "name": "tool_applications_company_name_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_applications_company_key_uq": {
+ "name": "tool_applications_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "application_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_applications_company_id_companies_id_fk": {
+ "name": "tool_applications_company_id_companies_id_fk",
+ "tableFrom": "tool_applications",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_applications_plugin_id_plugins_id_fk": {
+ "name": "tool_applications_plugin_id_plugins_id_fk",
+ "tableFrom": "tool_applications",
+ "tableTo": "plugins",
+ "columnsFrom": [
+ "plugin_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_applications_owner_agent_id_agents_id_fk": {
+ "name": "tool_applications_owner_agent_id_agents_id_fk",
+ "tableFrom": "tool_applications",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "owner_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_call_events": {
+ "name": "tool_call_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_id": {
+ "name": "gateway_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_token_id": {
+ "name": "gateway_token_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_public_id": {
+ "name": "gateway_public_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_subject_type": {
+ "name": "client_subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_subject_id": {
+ "name": "client_subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mcp_session_id": {
+ "name": "mcp_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "correlation_id": {
+ "name": "correlation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "catalog_entry_id": {
+ "name": "catalog_entry_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "invocation_id": {
+ "name": "invocation_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action_request_id": {
+ "name": "action_request_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "runtime_slot_id": {
+ "name": "runtime_slot_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tool_name": {
+ "name": "tool_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "decision": {
+ "name": "decision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "matched_policy_ids": {
+ "name": "matched_policy_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "reason_code": {
+ "name": "reason_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy_explanation": {
+ "name": "policy_explanation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_scope_summary": {
+ "name": "credential_scope_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "header_policy_summary": {
+ "name": "header_policy_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "latency_ms": {
+ "name": "latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "arguments_summary": {
+ "name": "arguments_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "request_hash": {
+ "name": "request_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "request_summary": {
+ "name": "request_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_hash": {
+ "name": "result_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_summary": {
+ "name": "result_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_size_bytes": {
+ "name": "result_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "redaction_plan": {
+ "name": "redaction_plan",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "rate_limit_state": {
+ "name": "rate_limit_state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_call_events_company_created_idx": {
+ "name": "tool_call_events_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_call_events_run_idx": {
+ "name": "tool_call_events_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_call_events_issue_idx": {
+ "name": "tool_call_events_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_call_events_invocation_idx": {
+ "name": "tool_call_events_invocation_idx",
+ "columns": [
+ {
+ "expression": "invocation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_call_events_gateway_idx": {
+ "name": "tool_call_events_gateway_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "gateway_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_call_events_company_id_companies_id_fk": {
+ "name": "tool_call_events_company_id_companies_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_agent_id_agents_id_fk": {
+ "name": "tool_call_events_agent_id_agents_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_run_id_heartbeat_runs_id_fk": {
+ "name": "tool_call_events_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_issue_id_issues_id_fk": {
+ "name": "tool_call_events_issue_id_issues_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_gateway_id_tool_mcp_gateways_id_fk": {
+ "name": "tool_call_events_gateway_id_tool_mcp_gateways_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_mcp_gateways",
+ "columnsFrom": [
+ "gateway_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk": {
+ "name": "tool_call_events_gateway_token_id_tool_mcp_gateway_tokens_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_mcp_gateway_tokens",
+ "columnsFrom": [
+ "gateway_token_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_application_id_tool_applications_id_fk": {
+ "name": "tool_call_events_application_id_tool_applications_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_connection_id_tool_connections_id_fk": {
+ "name": "tool_call_events_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk": {
+ "name": "tool_call_events_catalog_entry_id_tool_catalog_entries_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_catalog_entries",
+ "columnsFrom": [
+ "catalog_entry_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_invocation_id_tool_invocations_id_fk": {
+ "name": "tool_call_events_invocation_id_tool_invocations_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_invocations",
+ "columnsFrom": [
+ "invocation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_action_request_id_tool_action_requests_id_fk": {
+ "name": "tool_call_events_action_request_id_tool_action_requests_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_action_requests",
+ "columnsFrom": [
+ "action_request_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk": {
+ "name": "tool_call_events_runtime_slot_id_tool_runtime_slots_id_fk",
+ "tableFrom": "tool_call_events",
+ "tableTo": "tool_runtime_slots",
+ "columnsFrom": [
+ "runtime_slot_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_catalog_entries": {
+ "name": "tool_catalog_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entry_kind": {
+ "name": "entry_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'tool'"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_name": {
+ "name": "tool_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "output_schema": {
+ "name": "output_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "annotations": {
+ "name": "annotations",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "risk_level": {
+ "name": "risk_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'read'"
+ },
+ "is_read_only": {
+ "name": "is_read_only",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "is_write": {
+ "name": "is_write",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_destructive": {
+ "name": "is_destructive",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "version": {
+ "name": "version",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "version_hash": {
+ "name": "version_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "schema_hash": {
+ "name": "schema_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_seen_at": {
+ "name": "first_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_seen_at": {
+ "name": "last_seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_by_agent_id": {
+ "name": "reviewed_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_by_user_id": {
+ "name": "reviewed_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quarantined_at": {
+ "name": "quarantined_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "quarantine_reason": {
+ "name": "quarantine_reason",
+ "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": {
+ "tool_catalog_entries_company_idx": {
+ "name": "tool_catalog_entries_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_catalog_entries_application_idx": {
+ "name": "tool_catalog_entries_application_idx",
+ "columns": [
+ {
+ "expression": "application_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_catalog_entries_connection_idx": {
+ "name": "tool_catalog_entries_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_catalog_entries_company_status_idx": {
+ "name": "tool_catalog_entries_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_catalog_entries_connection_name_uq": {
+ "name": "tool_catalog_entries_connection_name_uq",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_catalog_entries_company_id_companies_id_fk": {
+ "name": "tool_catalog_entries_company_id_companies_id_fk",
+ "tableFrom": "tool_catalog_entries",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_catalog_entries_application_id_tool_applications_id_fk": {
+ "name": "tool_catalog_entries_application_id_tool_applications_id_fk",
+ "tableFrom": "tool_catalog_entries",
+ "tableTo": "tool_applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_catalog_entries_connection_id_tool_connections_id_fk": {
+ "name": "tool_catalog_entries_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_catalog_entries",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk": {
+ "name": "tool_catalog_entries_reviewed_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_catalog_entries",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "reviewed_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_connection_installs": {
+ "name": "tool_connection_installs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_connection_installs_company_target_idx": {
+ "name": "tool_connection_installs_company_target_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_connection_installs_connection_idx": {
+ "name": "tool_connection_installs_connection_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_connection_installs_target_uq": {
+ "name": "tool_connection_installs_target_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_connection_installs_company_id_companies_id_fk": {
+ "name": "tool_connection_installs_company_id_companies_id_fk",
+ "tableFrom": "tool_connection_installs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_connection_installs_connection_id_tool_connections_id_fk": {
+ "name": "tool_connection_installs_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_connection_installs",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_connection_installs_created_by_agent_id_agents_id_fk": {
+ "name": "tool_connection_installs_created_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_connection_installs",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "tool_connection_installs_target_type_check": {
+ "name": "tool_connection_installs_target_type_check",
+ "value": "\"tool_connection_installs\".\"target_type\" in ('company', 'agent')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.tool_connections": {
+ "name": "tool_connections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uid": {
+ "name": "uid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_kind": {
+ "name": "connection_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'managed'"
+ },
+ "ownership": {
+ "name": "ownership",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'customer'"
+ },
+ "transport": {
+ "name": "transport",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_kind": {
+ "name": "auth_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'draft'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "transport_config": {
+ "name": "transport_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "credential_refs": {
+ "name": "credential_refs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "credential_secret_refs": {
+ "name": "credential_secret_refs",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "health_status": {
+ "name": "health_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unchecked'"
+ },
+ "health_message": {
+ "name": "health_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "health_checked_at": {
+ "name": "health_checked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_health_at": {
+ "name": "last_health_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_catalog_refresh_at": {
+ "name": "last_catalog_refresh_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "tool_connections_company_idx": {
+ "name": "tool_connections_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_connections_application_idx": {
+ "name": "tool_connections_application_idx",
+ "columns": [
+ {
+ "expression": "application_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_connections_company_enabled_idx": {
+ "name": "tool_connections_company_enabled_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_connections_company_uid_uq": {
+ "name": "tool_connections_company_uid_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "uid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_connections_company_id_companies_id_fk": {
+ "name": "tool_connections_company_id_companies_id_fk",
+ "tableFrom": "tool_connections",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_connections_application_id_tool_applications_id_fk": {
+ "name": "tool_connections_application_id_tool_applications_id_fk",
+ "tableFrom": "tool_connections",
+ "tableTo": "tool_applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "tool_connections_created_by_agent_id_agents_id_fk": {
+ "name": "tool_connections_created_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_connections",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "tool_connections_company_id_uq": {
+ "name": "tool_connections_company_id_uq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "company_id",
+ "id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "tool_connections_ownership_check": {
+ "name": "tool_connections_ownership_check",
+ "value": "\"tool_connections\".\"ownership\" in ('platform_shared', 'platform_provisioned', 'customer', 'dcr')"
+ },
+ "tool_connections_transport_check": {
+ "name": "tool_connections_transport_check",
+ "value": "\"tool_connections\".\"transport\" in ('mcp_remote', 'rest_api', 'local_stdio')"
+ },
+ "tool_connections_auth_kind_check": {
+ "name": "tool_connections_auth_kind_check",
+ "value": "\"tool_connections\".\"auth_kind\" in ('oauth', 'api_key', 'none')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.tool_gateway_rate_limit_counters": {
+ "name": "tool_gateway_rate_limit_counters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter_key": {
+ "name": "counter_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "window_start_at": {
+ "name": "window_start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "window_ms": {
+ "name": "window_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "limit": {
+ "name": "limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "reset_at": {
+ "name": "reset_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": {
+ "tool_gateway_rate_limit_counters_company_idx": {
+ "name": "tool_gateway_rate_limit_counters_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_gateway_rate_limit_counters_window_uq": {
+ "name": "tool_gateway_rate_limit_counters_window_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "counter_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "window_start_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_gateway_rate_limit_counters_company_id_companies_id_fk": {
+ "name": "tool_gateway_rate_limit_counters_company_id_companies_id_fk",
+ "tableFrom": "tool_gateway_rate_limit_counters",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_gateway_sessions": {
+ "name": "tool_gateway_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_id": {
+ "name": "gateway_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_token_id": {
+ "name": "gateway_token_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_public_id": {
+ "name": "gateway_public_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_subject_type": {
+ "name": "client_subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_subject_id": {
+ "name": "client_subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mcp_session_id": {
+ "name": "mcp_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "correlation_id": {
+ "name": "correlation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_gateway_sessions_token_hash_uq": {
+ "name": "tool_gateway_sessions_token_hash_uq",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_gateway_sessions_company_agent_idx": {
+ "name": "tool_gateway_sessions_company_agent_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_gateway_sessions_company_expires_idx": {
+ "name": "tool_gateway_sessions_company_expires_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_gateway_sessions_run_idx": {
+ "name": "tool_gateway_sessions_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_gateway_sessions_issue_idx": {
+ "name": "tool_gateway_sessions_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_gateway_sessions_gateway_idx": {
+ "name": "tool_gateway_sessions_gateway_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "gateway_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_gateway_sessions_company_id_companies_id_fk": {
+ "name": "tool_gateway_sessions_company_id_companies_id_fk",
+ "tableFrom": "tool_gateway_sessions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_gateway_sessions_agent_id_agents_id_fk": {
+ "name": "tool_gateway_sessions_agent_id_agents_id_fk",
+ "tableFrom": "tool_gateway_sessions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_gateway_sessions_run_id_heartbeat_runs_id_fk": {
+ "name": "tool_gateway_sessions_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "tool_gateway_sessions",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_gateway_sessions_issue_id_issues_id_fk": {
+ "name": "tool_gateway_sessions_issue_id_issues_id_fk",
+ "tableFrom": "tool_gateway_sessions",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_gateway_sessions_project_id_projects_id_fk": {
+ "name": "tool_gateway_sessions_project_id_projects_id_fk",
+ "tableFrom": "tool_gateway_sessions",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk": {
+ "name": "tool_gateway_sessions_gateway_id_tool_mcp_gateways_id_fk",
+ "tableFrom": "tool_gateway_sessions",
+ "tableTo": "tool_mcp_gateways",
+ "columnsFrom": [
+ "gateway_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk": {
+ "name": "tool_gateway_sessions_gateway_token_id_tool_mcp_gateway_tokens_id_fk",
+ "tableFrom": "tool_gateway_sessions",
+ "tableTo": "tool_mcp_gateway_tokens",
+ "columnsFrom": [
+ "gateway_token_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_invocations": {
+ "name": "tool_invocations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "idempotency_key": {
+ "name": "idempotency_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_type": {
+ "name": "actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'system'"
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "run_id": {
+ "name": "run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_id": {
+ "name": "gateway_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_token_id": {
+ "name": "gateway_token_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gateway_public_id": {
+ "name": "gateway_public_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_subject_type": {
+ "name": "client_subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_subject_id": {
+ "name": "client_subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_name": {
+ "name": "client_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mcp_session_id": {
+ "name": "mcp_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "correlation_id": {
+ "name": "correlation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "catalog_entry_id": {
+ "name": "catalog_entry_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "catalog_version_hash": {
+ "name": "catalog_version_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "catalog_schema_hash": {
+ "name": "catalog_schema_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_type": {
+ "name": "provider_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "application_key": {
+ "name": "application_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "upstream_tool_name": {
+ "name": "upstream_tool_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "risk_level": {
+ "name": "risk_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tool_name": {
+ "name": "tool_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "arguments_hash": {
+ "name": "arguments_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "arguments_summary": {
+ "name": "arguments_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy_decision": {
+ "name": "policy_decision",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "matched_policy_ids": {
+ "name": "matched_policy_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "policy_explanation": {
+ "name": "policy_explanation",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_scope_summary": {
+ "name": "credential_scope_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "header_policy_summary": {
+ "name": "header_policy_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approval_state": {
+ "name": "approval_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'not_required'"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "upstream_request_id": {
+ "name": "upstream_request_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_hash": {
+ "name": "result_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_summary": {
+ "name": "result_summary",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_size_bytes": {
+ "name": "result_size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "result_artifact_id": {
+ "name": "result_artifact_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_code": {
+ "name": "error_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error_message": {
+ "name": "error_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "completed_at": {
+ "name": "completed_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_invocations_company_created_idx": {
+ "name": "tool_invocations_company_created_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_invocations_run_idx": {
+ "name": "tool_invocations_run_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_invocations_issue_idx": {
+ "name": "tool_invocations_issue_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_invocations_gateway_idx": {
+ "name": "tool_invocations_gateway_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "gateway_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_invocations_company_idempotency_uq": {
+ "name": "tool_invocations_company_idempotency_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "idempotency_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_invocations_company_id_companies_id_fk": {
+ "name": "tool_invocations_company_id_companies_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_agent_id_agents_id_fk": {
+ "name": "tool_invocations_agent_id_agents_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_issue_id_issues_id_fk": {
+ "name": "tool_invocations_issue_id_issues_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_run_id_heartbeat_runs_id_fk": {
+ "name": "tool_invocations_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_gateway_id_tool_mcp_gateways_id_fk": {
+ "name": "tool_invocations_gateway_id_tool_mcp_gateways_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "tool_mcp_gateways",
+ "columnsFrom": [
+ "gateway_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk": {
+ "name": "tool_invocations_gateway_token_id_tool_mcp_gateway_tokens_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "tool_mcp_gateway_tokens",
+ "columnsFrom": [
+ "gateway_token_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_application_id_tool_applications_id_fk": {
+ "name": "tool_invocations_application_id_tool_applications_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "tool_applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_connection_id_tool_connections_id_fk": {
+ "name": "tool_invocations_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk": {
+ "name": "tool_invocations_catalog_entry_id_tool_catalog_entries_id_fk",
+ "tableFrom": "tool_invocations",
+ "tableTo": "tool_catalog_entries",
+ "columnsFrom": [
+ "catalog_entry_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_mcp_gateway_tokens": {
+ "name": "tool_mcp_gateway_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "gateway_id": {
+ "name": "gateway_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,
+ "default": "''"
+ },
+ "subject_type": {
+ "name": "subject_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'gateway_client'"
+ },
+ "subject_id": {
+ "name": "subject_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "client_label": {
+ "name": "client_label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "owner_note": {
+ "name": "owner_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "allowed_actions": {
+ "name": "allowed_actions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[\"tools/list\",\"tools/call\"]'::jsonb"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_override_reason": {
+ "name": "expiry_override_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_override_by_user_id": {
+ "name": "expiry_override_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_override_by_agent_id": {
+ "name": "expiry_override_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expiry_override_at": {
+ "name": "expiry_override_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
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "tool_mcp_gateway_tokens_token_hash_uq": {
+ "name": "tool_mcp_gateway_tokens_token_hash_uq",
+ "columns": [
+ {
+ "expression": "token_hash",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateway_tokens_gateway_idx": {
+ "name": "tool_mcp_gateway_tokens_gateway_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "gateway_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateway_tokens_subject_idx": {
+ "name": "tool_mcp_gateway_tokens_subject_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "subject_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "subject_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateway_tokens_company_expires_idx": {
+ "name": "tool_mcp_gateway_tokens_company_expires_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_mcp_gateway_tokens_company_id_companies_id_fk": {
+ "name": "tool_mcp_gateway_tokens_company_id_companies_id_fk",
+ "tableFrom": "tool_mcp_gateway_tokens",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk": {
+ "name": "tool_mcp_gateway_tokens_gateway_id_tool_mcp_gateways_id_fk",
+ "tableFrom": "tool_mcp_gateway_tokens",
+ "tableTo": "tool_mcp_gateways",
+ "columnsFrom": [
+ "gateway_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk": {
+ "name": "tool_mcp_gateway_tokens_expiry_override_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_mcp_gateway_tokens",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "expiry_override_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk": {
+ "name": "tool_mcp_gateway_tokens_created_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_mcp_gateway_tokens",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_mcp_gateways": {
+ "name": "tool_mcp_gateways",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "gateway_public_id": {
+ "name": "gateway_public_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'gw_' || replace(gen_random_uuid()::text, '-', '')"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_slug": {
+ "name": "display_slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'active'"
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "default_profile_mode": {
+ "name": "default_profile_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'gateway_only'"
+ },
+ "context_scope_type": {
+ "name": "context_scope_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "context_scope_id": {
+ "name": "context_scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "approval_issue_id": {
+ "name": "approval_issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "auth_config": {
+ "name": "auth_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"version\":1,\"bearer\":{\"enabled\":true,\"tokenPrefix\":\"pcgw\",\"defaultTtlSeconds\":7776000,\"requireFiniteExpiry\":true,\"longLivedTokenRequiresOverride\":true},\"oauth\":{\"enabled\":false,\"reservedFor\":\"v1_5\",\"dynamicClientRegistration\":false,\"authorizationCodePkce\":false}}'::jsonb"
+ },
+ "header_policy": {
+ "name": "header_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"version\":1,\"callerPassthrough\":{\"enabled\":false,\"allowedHeaders\":[]},\"staticHeaders\":[],\"generatedMetadata\":{\"enabled\":false,\"allowedHeaders\":[]},\"responseHeaders\":{\"forwardMcpRequiredHeaders\":true,\"forwardSafeCacheHeaders\":true}}'::jsonb"
+ },
+ "metadata_policy": {
+ "name": "metadata_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"version\":1,\"forwardCompanyId\":false,\"forwardGatewayId\":false,\"forwardProjectId\":false,\"forwardIssueId\":false,\"forwardAgentId\":false,\"forwardRunId\":false,\"forwardCorrelationId\":true}'::jsonb"
+ },
+ "on_demand_tools_config": {
+ "name": "on_demand_tools_config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"enabled\":false,\"searchToolName\":\"search_tools\",\"runToolName\":\"run_tool\"}'::jsonb"
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "archived_at": {
+ "name": "archived_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_mcp_gateways_company_idx": {
+ "name": "tool_mcp_gateways_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateways_company_status_idx": {
+ "name": "tool_mcp_gateways_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateways_profile_idx": {
+ "name": "tool_mcp_gateways_profile_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "profile_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateways_public_id_uq": {
+ "name": "tool_mcp_gateways_public_id_uq",
+ "columns": [
+ {
+ "expression": "gateway_public_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateways_company_slug_uq": {
+ "name": "tool_mcp_gateways_company_slug_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_mcp_gateways_company_name_uq": {
+ "name": "tool_mcp_gateways_company_name_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_mcp_gateways_company_id_companies_id_fk": {
+ "name": "tool_mcp_gateways_company_id_companies_id_fk",
+ "tableFrom": "tool_mcp_gateways",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateways_profile_id_tool_profiles_id_fk": {
+ "name": "tool_mcp_gateways_profile_id_tool_profiles_id_fk",
+ "tableFrom": "tool_mcp_gateways",
+ "tableTo": "tool_profiles",
+ "columnsFrom": [
+ "profile_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateways_agent_id_agents_id_fk": {
+ "name": "tool_mcp_gateways_agent_id_agents_id_fk",
+ "tableFrom": "tool_mcp_gateways",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateways_project_id_projects_id_fk": {
+ "name": "tool_mcp_gateways_project_id_projects_id_fk",
+ "tableFrom": "tool_mcp_gateways",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateways_issue_id_issues_id_fk": {
+ "name": "tool_mcp_gateways_issue_id_issues_id_fk",
+ "tableFrom": "tool_mcp_gateways",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateways_approval_issue_id_issues_id_fk": {
+ "name": "tool_mcp_gateways_approval_issue_id_issues_id_fk",
+ "tableFrom": "tool_mcp_gateways",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "approval_issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_mcp_gateways_created_by_agent_id_agents_id_fk": {
+ "name": "tool_mcp_gateways_created_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_mcp_gateways",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_oauth_states": {
+ "name": "tool_oauth_states",
+ "schema": "",
+ "columns": {
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code_verifier": {
+ "name": "code_verifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by_actor_type": {
+ "name": "created_by_actor_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_actor_id": {
+ "name": "created_by_actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_session_id": {
+ "name": "created_by_session_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "subject_user_id": {
+ "name": "subject_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "requested_scopes": {
+ "name": "requested_scopes",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "return_to": {
+ "name": "return_to",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "interaction_id": {
+ "name": "interaction_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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()"
+ }
+ },
+ "indexes": {
+ "tool_oauth_states_company_idx": {
+ "name": "tool_oauth_states_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_oauth_states_connection_idx": {
+ "name": "tool_oauth_states_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_oauth_states_actor_idx": {
+ "name": "tool_oauth_states_actor_idx",
+ "columns": [
+ {
+ "expression": "created_by_actor_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_by_actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_oauth_states_expires_at_idx": {
+ "name": "tool_oauth_states_expires_at_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_oauth_states_company_id_companies_id_fk": {
+ "name": "tool_oauth_states_company_id_companies_id_fk",
+ "tableFrom": "tool_oauth_states",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_oauth_states_connection_id_tool_connections_id_fk": {
+ "name": "tool_oauth_states_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_oauth_states",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_policies": {
+ "name": "tool_policies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "policy_type": {
+ "name": "policy_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "selectors": {
+ "name": "selectors",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "tool_policies_company_enabled_idx": {
+ "name": "tool_policies_company_enabled_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_policies_company_type_idx": {
+ "name": "tool_policies_company_type_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "policy_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_policies_company_name_uq": {
+ "name": "tool_policies_company_name_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_policies_company_id_companies_id_fk": {
+ "name": "tool_policies_company_id_companies_id_fk",
+ "tableFrom": "tool_policies",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_policies_created_by_agent_id_agents_id_fk": {
+ "name": "tool_policies_created_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_policies",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_profile_bindings": {
+ "name": "tool_profile_bindings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "priority": {
+ "name": "priority",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "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": {
+ "tool_profile_bindings_company_target_idx": {
+ "name": "tool_profile_bindings_company_target_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_profile_bindings_target_profile_uq": {
+ "name": "tool_profile_bindings_target_profile_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "profile_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_profile_bindings_company_id_companies_id_fk": {
+ "name": "tool_profile_bindings_company_id_companies_id_fk",
+ "tableFrom": "tool_profile_bindings",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_profile_bindings_profile_id_tool_profiles_id_fk": {
+ "name": "tool_profile_bindings_profile_id_tool_profiles_id_fk",
+ "tableFrom": "tool_profile_bindings",
+ "tableTo": "tool_profiles",
+ "columnsFrom": [
+ "profile_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_profile_bindings_created_by_agent_id_agents_id_fk": {
+ "name": "tool_profile_bindings_created_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_profile_bindings",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_profile_entries": {
+ "name": "tool_profile_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_id": {
+ "name": "profile_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "selector_type": {
+ "name": "selector_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "effect": {
+ "name": "effect",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'include'"
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "catalog_entry_id": {
+ "name": "catalog_entry_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tool_name": {
+ "name": "tool_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "risk_level": {
+ "name": "risk_level",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "conditions": {
+ "name": "conditions",
+ "type": "jsonb",
+ "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": {
+ "tool_profile_entries_company_profile_idx": {
+ "name": "tool_profile_entries_company_profile_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "profile_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_profile_entries_application_idx": {
+ "name": "tool_profile_entries_application_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "application_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_profile_entries_connection_idx": {
+ "name": "tool_profile_entries_connection_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_profile_entries_catalog_entry_idx": {
+ "name": "tool_profile_entries_catalog_entry_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "catalog_entry_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_profile_entries_company_id_companies_id_fk": {
+ "name": "tool_profile_entries_company_id_companies_id_fk",
+ "tableFrom": "tool_profile_entries",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_profile_entries_profile_id_tool_profiles_id_fk": {
+ "name": "tool_profile_entries_profile_id_tool_profiles_id_fk",
+ "tableFrom": "tool_profile_entries",
+ "tableTo": "tool_profiles",
+ "columnsFrom": [
+ "profile_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_profile_entries_application_id_tool_applications_id_fk": {
+ "name": "tool_profile_entries_application_id_tool_applications_id_fk",
+ "tableFrom": "tool_profile_entries",
+ "tableTo": "tool_applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_profile_entries_connection_id_tool_connections_id_fk": {
+ "name": "tool_profile_entries_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_profile_entries",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk": {
+ "name": "tool_profile_entries_catalog_entry_id_tool_catalog_entries_id_fk",
+ "tableFrom": "tool_profile_entries",
+ "tableTo": "tool_catalog_entries",
+ "columnsFrom": [
+ "catalog_entry_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_profiles": {
+ "name": "tool_profiles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "profile_key": {
+ "name": "profile_key",
+ "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'"
+ },
+ "default_action": {
+ "name": "default_action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'deny'"
+ },
+ "new_tools_reviewed_at": {
+ "name": "new_tools_reviewed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "tool_profiles_company_status_idx": {
+ "name": "tool_profiles_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_profiles_company_key_uq": {
+ "name": "tool_profiles_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "profile_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_profiles_company_name_uq": {
+ "name": "tool_profiles_company_name_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_profiles_company_id_companies_id_fk": {
+ "name": "tool_profiles_company_id_companies_id_fk",
+ "tableFrom": "tool_profiles",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_rate_limit_counters": {
+ "name": "tool_rate_limit_counters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "policy_id": {
+ "name": "policy_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter_key": {
+ "name": "counter_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_type": {
+ "name": "scope_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "window_kind": {
+ "name": "window_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "window_start_at": {
+ "name": "window_start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "limit": {
+ "name": "limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remaining": {
+ "name": "remaining",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reset_at": {
+ "name": "reset_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": {
+ "tool_rate_limit_counters_company_idx": {
+ "name": "tool_rate_limit_counters_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_rate_limit_counters_window_uq": {
+ "name": "tool_rate_limit_counters_window_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "policy_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "counter_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "window_kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "window_start_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_rate_limit_counters_company_id_companies_id_fk": {
+ "name": "tool_rate_limit_counters_company_id_companies_id_fk",
+ "tableFrom": "tool_rate_limit_counters",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_rate_limit_counters_policy_id_tool_policies_id_fk": {
+ "name": "tool_rate_limit_counters_policy_id_tool_policies_id_fk",
+ "tableFrom": "tool_rate_limit_counters",
+ "tableTo": "tool_policies",
+ "columnsFrom": [
+ "policy_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_runtime_metric_counters": {
+ "name": "tool_runtime_metric_counters",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metric": {
+ "name": "metric",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bucket_start_at": {
+ "name": "bucket_start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "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": {
+ "tool_runtime_metric_counters_company_metric_idx": {
+ "name": "tool_runtime_metric_counters_company_metric_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "metric",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "bucket_start_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_runtime_metric_counters_bucket_uq": {
+ "name": "tool_runtime_metric_counters_bucket_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "metric",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "bucket_start_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_runtime_metric_counters_company_id_companies_id_fk": {
+ "name": "tool_runtime_metric_counters_company_id_companies_id_fk",
+ "tableFrom": "tool_runtime_metric_counters",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_runtime_slots": {
+ "name": "tool_runtime_slots",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "application_id": {
+ "name": "application_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "connection_id": {
+ "name": "connection_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_workspace_id": {
+ "name": "project_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_workspace_id": {
+ "name": "execution_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_scope_type": {
+ "name": "owner_scope_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'connection'"
+ },
+ "owner_scope_id": {
+ "name": "owner_scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "runtime_kind": {
+ "name": "runtime_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local_stdio'"
+ },
+ "slot_key": {
+ "name": "slot_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'stopped'"
+ },
+ "reuse_key": {
+ "name": "reuse_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "workspace_scope": {
+ "name": "workspace_scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "credential_scope_hash": {
+ "name": "credential_scope_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "process_id": {
+ "name": "process_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "command_template_key": {
+ "name": "command_template_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "health_status": {
+ "name": "health_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unchecked'"
+ },
+ "health_message": {
+ "name": "health_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_health_check_at": {
+ "name": "last_health_check_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_started_at": {
+ "name": "last_started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stopped_at": {
+ "name": "stopped_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
+ },
+ "idle_expires_at": {
+ "name": "idle_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idle_deadline_at": {
+ "name": "idle_deadline_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "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": {
+ "tool_runtime_slots_company_idx": {
+ "name": "tool_runtime_slots_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_runtime_slots_connection_idx": {
+ "name": "tool_runtime_slots_connection_idx",
+ "columns": [
+ {
+ "expression": "connection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_runtime_slots_execution_workspace_idx": {
+ "name": "tool_runtime_slots_execution_workspace_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "execution_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_runtime_slots_slot_key_uq": {
+ "name": "tool_runtime_slots_slot_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "slot_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_runtime_slots_company_id_companies_id_fk": {
+ "name": "tool_runtime_slots_company_id_companies_id_fk",
+ "tableFrom": "tool_runtime_slots",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_runtime_slots_application_id_tool_applications_id_fk": {
+ "name": "tool_runtime_slots_application_id_tool_applications_id_fk",
+ "tableFrom": "tool_runtime_slots",
+ "tableTo": "tool_applications",
+ "columnsFrom": [
+ "application_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_runtime_slots_connection_id_tool_connections_id_fk": {
+ "name": "tool_runtime_slots_connection_id_tool_connections_id_fk",
+ "tableFrom": "tool_runtime_slots",
+ "tableTo": "tool_connections",
+ "columnsFrom": [
+ "connection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk": {
+ "name": "tool_runtime_slots_project_workspace_id_project_workspaces_id_fk",
+ "tableFrom": "tool_runtime_slots",
+ "tableTo": "project_workspaces",
+ "columnsFrom": [
+ "project_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk": {
+ "name": "tool_runtime_slots_execution_workspace_id_execution_workspaces_id_fk",
+ "tableFrom": "tool_runtime_slots",
+ "tableTo": "execution_workspaces",
+ "columnsFrom": [
+ "execution_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "tool_runtime_slots_issue_id_issues_id_fk": {
+ "name": "tool_runtime_slots_issue_id_issues_id_fk",
+ "tableFrom": "tool_runtime_slots",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.tool_stdio_command_templates": {
+ "name": "tool_stdio_command_templates",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template_key": {
+ "name": "template_key",
+ "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'"
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "args": {
+ "name": "args",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "env_keys": {
+ "name": "env_keys",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "tools": {
+ "name": "tools",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disabled_at": {
+ "name": "disabled_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "tool_stdio_command_templates_company_idx": {
+ "name": "tool_stdio_command_templates_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_stdio_command_templates_company_status_idx": {
+ "name": "tool_stdio_command_templates_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "tool_stdio_command_templates_company_key_uq": {
+ "name": "tool_stdio_command_templates_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "template_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "tool_stdio_command_templates_company_id_companies_id_fk": {
+ "name": "tool_stdio_command_templates_company_id_companies_id_fk",
+ "tableFrom": "tool_stdio_command_templates",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tool_stdio_command_templates_created_by_agent_id_agents_id_fk": {
+ "name": "tool_stdio_command_templates_created_by_agent_id_agents_id_fk",
+ "tableFrom": "tool_stdio_command_templates",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_inbox_agent_policies": {
+ "name": "user_inbox_agent_policies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "allowed_agent_ids": {
+ "name": "allowed_agent_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "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": {
+ "user_inbox_agent_policies_company_user_uq": {
+ "name": "user_inbox_agent_policies_company_user_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_inbox_agent_policies_allowed_agent_ids_idx": {
+ "name": "user_inbox_agent_policies_allowed_agent_ids_idx",
+ "columns": [
+ {
+ "expression": "allowed_agent_ids",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "gin",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_inbox_agent_policies_company_id_companies_id_fk": {
+ "name": "user_inbox_agent_policies_company_id_companies_id_fk",
+ "tableFrom": "user_inbox_agent_policies",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "user_inbox_agent_policies_mode_check": {
+ "name": "user_inbox_agent_policies_mode_check",
+ "value": "\"user_inbox_agent_policies\".\"mode\" in ('open', 'allowlist', 'disabled')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.user_secret_declarations": {
+ "name": "user_secret_declarations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_secret_definition_id": {
+ "name": "user_secret_definition_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "config_path": {
+ "name": "config_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "env_key": {
+ "name": "env_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "version_selector": {
+ "name": "version_selector",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'latest'"
+ },
+ "required": {
+ "name": "required",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "allow_missing_override": {
+ "name": "allow_missing_override",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "label": {
+ "name": "label",
+ "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": {
+ "user_secret_declarations_company_idx": {
+ "name": "user_secret_declarations_company_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_declarations_definition_idx": {
+ "name": "user_secret_declarations_definition_idx",
+ "columns": [
+ {
+ "expression": "user_secret_definition_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_declarations_target_idx": {
+ "name": "user_secret_declarations_target_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_declarations_company_required_idx": {
+ "name": "user_secret_declarations_company_required_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "required",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_declarations_target_path_uq": {
+ "name": "user_secret_declarations_target_path_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "config_path",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_declarations_required_override_idx": {
+ "name": "user_secret_declarations_required_override_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "allow_missing_override",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"user_secret_declarations\".\"allow_missing_override\" = true",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_secret_declarations_company_id_companies_id_fk": {
+ "name": "user_secret_declarations_company_id_companies_id_fk",
+ "tableFrom": "user_secret_declarations",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk": {
+ "name": "user_secret_declarations_user_secret_definition_id_user_secret_definitions_id_fk",
+ "tableFrom": "user_secret_declarations",
+ "tableTo": "user_secret_definitions",
+ "columnsFrom": [
+ "user_secret_definition_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_secret_definitions": {
+ "name": "user_secret_definitions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "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'"
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'local_encrypted'"
+ },
+ "managed_mode": {
+ "name": "managed_mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'paperclip_managed'"
+ },
+ "provider_config_id": {
+ "name": "provider_config_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_metadata": {
+ "name": "provider_metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "usage_guidance": {
+ "name": "usage_guidance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_agent_id": {
+ "name": "created_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by_user_id": {
+ "name": "created_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_agent_id": {
+ "name": "updated_by_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_user_id": {
+ "name": "updated_by_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "user_secret_definitions_company_status_idx": {
+ "name": "user_secret_definitions_company_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_definitions_company_provider_idx": {
+ "name": "user_secret_definitions_company_provider_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_definitions_provider_config_idx": {
+ "name": "user_secret_definitions_provider_config_idx",
+ "columns": [
+ {
+ "expression": "provider_config_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "user_secret_definitions_company_key_uq": {
+ "name": "user_secret_definitions_company_key_uq",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"user_secret_definitions\".\"deleted_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_secret_definitions_company_id_companies_id_fk": {
+ "name": "user_secret_definitions_company_id_companies_id_fk",
+ "tableFrom": "user_secret_definitions",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk": {
+ "name": "user_secret_definitions_provider_config_id_company_secret_provider_configs_id_fk",
+ "tableFrom": "user_secret_definitions",
+ "tableTo": "company_secret_provider_configs",
+ "columnsFrom": [
+ "provider_config_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_secret_definitions_created_by_agent_id_agents_id_fk": {
+ "name": "user_secret_definitions_created_by_agent_id_agents_id_fk",
+ "tableFrom": "user_secret_definitions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "created_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_secret_definitions_updated_by_agent_id_agents_id_fk": {
+ "name": "user_secret_definitions_updated_by_agent_id_agents_id_fk",
+ "tableFrom": "user_secret_definitions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "updated_by_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_sidebar_preferences": {
+ "name": "user_sidebar_preferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company_order": {
+ "name": "company_order",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "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": {
+ "user_sidebar_preferences_user_uq": {
+ "name": "user_sidebar_preferences_user_uq",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_operations": {
+ "name": "workspace_operations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "execution_workspace_id": {
+ "name": "execution_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "heartbeat_run_id": {
+ "name": "heartbeat_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "phase": {
+ "name": "phase",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cwd": {
+ "name": "cwd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'running'"
+ },
+ "exit_code": {
+ "name": "exit_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_store": {
+ "name": "log_store",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_ref": {
+ "name": "log_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_bytes": {
+ "name": "log_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_sha256": {
+ "name": "log_sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "log_compressed": {
+ "name": "log_compressed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "stdout_excerpt": {
+ "name": "stdout_excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stderr_excerpt": {
+ "name": "stderr_excerpt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_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()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "workspace_operations_company_run_started_idx": {
+ "name": "workspace_operations_company_run_started_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "heartbeat_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_operations_company_workspace_started_idx": {
+ "name": "workspace_operations_company_workspace_started_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "execution_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_operations_company_workspace_issue_started_idx": {
+ "name": "workspace_operations_company_workspace_issue_started_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "execution_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "issue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_operations_company_id_companies_id_fk": {
+ "name": "workspace_operations_company_id_companies_id_fk",
+ "tableFrom": "workspace_operations",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": {
+ "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk",
+ "tableFrom": "workspace_operations",
+ "tableTo": "execution_workspaces",
+ "columnsFrom": [
+ "execution_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": {
+ "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "workspace_operations",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "heartbeat_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "workspace_operations_issue_id_issues_id_fk": {
+ "name": "workspace_operations_issue_id_issues_id_fk",
+ "tableFrom": "workspace_operations",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.workspace_runtime_services": {
+ "name": "workspace_runtime_services",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "project_id": {
+ "name": "project_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "project_workspace_id": {
+ "name": "project_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "execution_workspace_id": {
+ "name": "execution_workspace_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "issue_id": {
+ "name": "issue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope_type": {
+ "name": "scope_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope_id": {
+ "name": "scope_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "service_name": {
+ "name": "service_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "lifecycle": {
+ "name": "lifecycle",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reuse_key": {
+ "name": "reuse_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "command": {
+ "name": "command",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cwd": {
+ "name": "cwd",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_ref": {
+ "name": "provider_ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "owner_agent_id": {
+ "name": "owner_agent_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "started_by_run_id": {
+ "name": "started_by_run_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_used_at": {
+ "name": "last_used_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": true,
+ "default": "now()"
+ },
+ "stopped_at": {
+ "name": "stopped_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "stop_policy": {
+ "name": "stop_policy",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "health_status": {
+ "name": "health_status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "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": {
+ "workspace_runtime_services_company_workspace_status_idx": {
+ "name": "workspace_runtime_services_company_workspace_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_runtime_services_company_execution_workspace_status_idx": {
+ "name": "workspace_runtime_services_company_execution_workspace_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "execution_workspace_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_runtime_services_company_project_status_idx": {
+ "name": "workspace_runtime_services_company_project_status_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "project_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_runtime_services_run_idx": {
+ "name": "workspace_runtime_services_run_idx",
+ "columns": [
+ {
+ "expression": "started_by_run_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "workspace_runtime_services_company_updated_idx": {
+ "name": "workspace_runtime_services_company_updated_idx",
+ "columns": [
+ {
+ "expression": "company_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "workspace_runtime_services_company_id_companies_id_fk": {
+ "name": "workspace_runtime_services_company_id_companies_id_fk",
+ "tableFrom": "workspace_runtime_services",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ },
+ "workspace_runtime_services_project_id_projects_id_fk": {
+ "name": "workspace_runtime_services_project_id_projects_id_fk",
+ "tableFrom": "workspace_runtime_services",
+ "tableTo": "projects",
+ "columnsFrom": [
+ "project_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": {
+ "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk",
+ "tableFrom": "workspace_runtime_services",
+ "tableTo": "project_workspaces",
+ "columnsFrom": [
+ "project_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": {
+ "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk",
+ "tableFrom": "workspace_runtime_services",
+ "tableTo": "execution_workspaces",
+ "columnsFrom": [
+ "execution_workspace_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "workspace_runtime_services_issue_id_issues_id_fk": {
+ "name": "workspace_runtime_services_issue_id_issues_id_fk",
+ "tableFrom": "workspace_runtime_services",
+ "tableTo": "issues",
+ "columnsFrom": [
+ "issue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "workspace_runtime_services_owner_agent_id_agents_id_fk": {
+ "name": "workspace_runtime_services_owner_agent_id_agents_id_fk",
+ "tableFrom": "workspace_runtime_services",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "owner_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": {
+ "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk",
+ "tableFrom": "workspace_runtime_services",
+ "tableTo": "heartbeat_runs",
+ "columnsFrom": [
+ "started_by_run_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index 9961a106f56..ed4a7ade926 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -1464,6 +1464,13 @@
"when": 1786032087897,
"tag": "0210_heartbeat_context_taskkey_index",
"breakpoints": true
+ },
+ {
+ "idx": 211,
+ "version": "7",
+ "when": 1786129601533,
+ "tag": "0211_bright_morg",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/schema/tool_access.ts b/packages/db/src/schema/tool_access.ts
index a9881d1e8da..01661b63443 100644
--- a/packages/db/src/schema/tool_access.ts
+++ b/packages/db/src/schema/tool_access.ts
@@ -141,7 +141,6 @@ export const toolConnections = pgTable(
index("tool_connections_company_idx").on(table.companyId),
index("tool_connections_application_idx").on(table.applicationId),
index("tool_connections_company_enabled_idx").on(table.companyId, table.enabled),
- uniqueIndex("tool_connections_company_name_uq").on(table.companyId, table.name),
uniqueIndex("tool_connections_company_uid_uq").on(table.companyId, table.uid),
unique("tool_connections_company_id_uq").on(table.companyId, table.id),
],
diff --git a/packages/shared/src/validators/tool-access.ts b/packages/shared/src/validators/tool-access.ts
index fc2f2f79641..01071fff7c9 100644
--- a/packages/shared/src/validators/tool-access.ts
+++ b/packages/shared/src/validators/tool-access.ts
@@ -281,6 +281,7 @@ export type ReconnectToolApp = z.infer;
export const finishToolAppSchema = z.object({
enabledCatalogEntryIds: z.array(z.string().uuid()).max(500).default([]),
askFirstCatalogEntryIds: z.array(z.string().uuid()).max(500).default([]),
+ reviewedCatalogEntryIds: z.array(z.string().uuid()).max(500).optional(),
access: z.union([
z.literal("all_agents"),
z.object({ agentIds: z.array(z.string().uuid()).min(1).max(250) }),
diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts
index 7a938d83563..0f25b8fba3d 100644
--- a/server/src/__tests__/tool-access-service.test.ts
+++ b/server/src/__tests__/tool-access-service.test.ts
@@ -3045,7 +3045,7 @@ describeEmbeddedPostgres("tool access service", () => {
expect(callbackRes.body.connection).toMatchObject({
id: connectRes.body.connectionId,
status: "active",
- enabled: false,
+ enabled: true,
credentialSecretRefs: [
expect.objectContaining({ configPath: "oauth.access_token", label: "OAuth access token" }),
expect.objectContaining({ configPath: "oauth.refresh_token", label: "OAuth refresh token" }),
@@ -3713,7 +3713,7 @@ describeEmbeddedPostgres("tool access service", () => {
.select()
.from(toolConnections)
.where(eq(toolConnections.id, connect.connectionId));
- expect(preserved).toMatchObject({ status: "active", enabled: false });
+ expect(preserved).toMatchObject({ status: "active", enabled: true });
expect(preserved.credentialSecretRefs.map((ref) => ref.configPath)).toEqual(expect.arrayContaining([
"oauth.access_token",
"oauth.refresh_token",
@@ -4501,6 +4501,36 @@ describeEmbeddedPostgres("tool access service", () => {
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({ status: 404 });
});
+ it("allows multiple same-named connections on one application", async () => {
+ const company = await createCompany(db);
+ const service = toolAccessService(db);
+ mockToolsList([
+ {
+ name: "read_items",
+ description: "Read items.",
+ inputSchema: { type: "object", properties: {} },
+ annotations: { readOnlyHint: true },
+ },
+ ]);
+
+ const first = await service.connectGalleryApp(company.id, {
+ link: "https://first.example.test/actions",
+ name: "Notion",
+ }, { actorType: "user", actorId: "board" });
+ const second = await service.connectGalleryApp(company.id, {
+ link: "https://second.example.test/actions",
+ name: "Notion",
+ applicationId: first.application.id,
+ }, { actorType: "user", actorId: "board" });
+
+ expect(second.application.id).toBe(first.application.id);
+ expect(second.connectionId).not.toBe(first.connectionId);
+ const rows = await db.select().from(toolConnections).where(eq(toolConnections.applicationId, first.application.id));
+ expect(rows).toHaveLength(2);
+ expect(rows.map((row) => row.name)).toEqual(["Notion", "Notion"]);
+ expect(new Set(rows.map((row) => row.uid))).toHaveProperty("size", 2);
+ });
+
it("does not delete a reused application when the connect rolls back", async () => {
const company = await createCompany(db);
const service = toolAccessService(db);
@@ -5084,6 +5114,56 @@ describeEmbeddedPostgres("tool access service", () => {
catalogEntryId: updateEntry.id,
}),
});
+
+ const createEntry = rereview.catalog.find((entry) => entry.toolName === "create_zap")!;
+ await service.finishGalleryAppConnection(company.id, connect.connectionId, {
+ enabledCatalogEntryIds: [listEntry.id, updateEntry.id],
+ askFirstCatalogEntryIds: [updateEntry.id],
+ access: { agentIds: [agent.id] },
+ }, { actorType: "user", actorId: "board" });
+ const stillQuarantined = await db
+ .select()
+ .from(toolCatalogEntries)
+ .where(eq(toolCatalogEntries.connectionId, connect.connectionId));
+ expect(stillQuarantined).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: updateEntry.id, status: "quarantined" }),
+ expect.objectContaining({ id: createEntry.id, status: "quarantined" }),
+ ]));
+
+ await expect(service.finishGalleryAppConnection(company.id, connect.connectionId, {
+ enabledCatalogEntryIds: [listEntry.id, createEntry.id],
+ askFirstCatalogEntryIds: [createEntry.id],
+ reviewedCatalogEntryIds: [createEntry.id],
+ access: { agentIds: [agent.id] },
+ }, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
+ status: 400,
+ message: "Action review decisions must cover every currently quarantined action exactly once",
+ });
+
+ const reviewed = await service.finishGalleryAppConnection(company.id, connect.connectionId, {
+ enabledCatalogEntryIds: [listEntry.id, createEntry.id],
+ askFirstCatalogEntryIds: [createEntry.id],
+ reviewedCatalogEntryIds: [updateEntry.id, createEntry.id],
+ access: { agentIds: [agent.id] },
+ }, { actorType: "user", actorId: "board" });
+
+ expect(reviewed.profileEntries).toEqual(expect.arrayContaining([
+ expect.objectContaining({ catalogEntryId: listEntry.id }),
+ expect.objectContaining({ catalogEntryId: createEntry.id }),
+ ]));
+ expect(reviewed.profileEntries).not.toEqual(expect.arrayContaining([
+ expect.objectContaining({ catalogEntryId: updateEntry.id }),
+ ]));
+ const reviewedCatalog = await db
+ .select()
+ .from(toolCatalogEntries)
+ .where(eq(toolCatalogEntries.connectionId, connect.connectionId));
+ expect(reviewedCatalog).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: updateEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null }),
+ expect.objectContaining({ id: createEntry.id, status: "active", reviewedAt: expect.any(Date), quarantineReason: null }),
+ ]));
+ const attentionAfterReview = await service.listAppsNeedingAttention(company.id);
+ expect(attentionAfterReview.apps).toEqual([]);
});
it("resolves Notion reads as allowed, mutations as ask-first, and denies cross-company use", async () => {
diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts
index 4469846a1e0..836f98ca885 100644
--- a/server/src/routes/tool-access.ts
+++ b/server/src/routes/tool-access.ts
@@ -378,6 +378,7 @@ export function toolAccessRoutes(
profileEntryCount: result.profileEntries.length,
profileBindingCount: result.profileBindings.length,
askFirstPolicyCount: result.policies.length,
+ reviewedCatalogEntryCount: req.body.reviewedCatalogEntryIds?.length ?? 0,
access: req.body.access,
},
});
diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts
index a399f9fba86..3ef102ab926 100644
--- a/server/src/services/tool-access.ts
+++ b/server/src/services/tool-access.ts
@@ -5177,8 +5177,31 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
const connection = await getConnectionRow(connectionId, companyId);
if (connection.status === "archived") throw conflict("Archived app connections cannot be finished");
const enabledIds = [...new Set([...input.enabledCatalogEntryIds, ...input.askFirstCatalogEntryIds])];
+ const requestedReviewedIds = input.reviewedCatalogEntryIds ?? [];
+ const reviewedIds = [...new Set(requestedReviewedIds)];
+ if (reviewedIds.length !== requestedReviewedIds.length) {
+ throw badRequest("Action review decisions must not contain duplicate catalogEntryId values");
+ }
const enabledRows = await assertCatalogEntriesForConnection(companyId, connection.id, enabledIds);
const askFirstRows = await assertCatalogEntriesForConnection(companyId, connection.id, input.askFirstCatalogEntryIds);
+ if (reviewedIds.length > 0) {
+ await assertCatalogEntriesForConnection(companyId, connection.id, reviewedIds);
+ const quarantinedRows = await db
+ .select({ id: toolCatalogEntries.id })
+ .from(toolCatalogEntries)
+ .where(and(
+ eq(toolCatalogEntries.companyId, companyId),
+ eq(toolCatalogEntries.connectionId, connection.id),
+ eq(toolCatalogEntries.status, "quarantined"),
+ ));
+ const reviewedIdSet = new Set(reviewedIds);
+ if (
+ quarantinedRows.length !== reviewedIdSet.size
+ || quarantinedRows.some((entry) => !reviewedIdSet.has(entry.id))
+ ) {
+ throw badRequest("Action review decisions must cover every currently quarantined action exactly once");
+ }
+ }
if (input.access !== "all_agents") await assertAgentsInCompany(companyId, input.access.agentIds);
const entries: CreateToolProfileEntryForProfile[] = enabledRows.map((entry) => ({
@@ -5281,6 +5304,25 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
}
const reviewedAt = new Date();
+ if (reviewedIds.length > 0) {
+ await tx
+ .update(toolCatalogEntries)
+ .set({
+ status: "active",
+ reviewedAt,
+ reviewedByAgentId: actor?.actorType === "agent" ? actor.actorId ?? null : null,
+ reviewedByUserId: actor?.actorType === "user" ? actor.actorId ?? null : null,
+ quarantinedAt: null,
+ quarantineReason: null,
+ updatedAt: reviewedAt,
+ })
+ .where(and(
+ eq(toolCatalogEntries.companyId, companyId),
+ eq(toolCatalogEntries.connectionId, connection.id),
+ inArray(toolCatalogEntries.id, reviewedIds),
+ eq(toolCatalogEntries.status, "quarantined"),
+ ));
+ }
if (enabledIds.length > 0) {
await tx
.update(toolCatalogEntries)
@@ -5293,7 +5335,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
quarantineReason: null,
updatedAt: reviewedAt,
})
- .where(and(eq(toolCatalogEntries.companyId, companyId), inArray(toolCatalogEntries.id, enabledIds)));
+ .where(and(
+ eq(toolCatalogEntries.companyId, companyId),
+ inArray(toolCatalogEntries.id, enabledIds),
+ ne(toolCatalogEntries.status, "quarantined"),
+ ));
}
const policies = await upsertAskFirstPolicies({
@@ -5720,7 +5766,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
.update(toolConnections)
.set({
status: "active",
- enabled: isSmokeLabOAuthFixture(connection) ? true : false,
+ enabled: true,
config: nextConfig,
transportConfig: nextConfig,
credentialSecretRefs: nextCredentialSecretRefs,
diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts
index e795d03f414..c338b237d64 100644
--- a/tests/e2e/app-not-connected.spec.ts
+++ b/tests/e2e/app-not-connected.spec.ts
@@ -143,14 +143,16 @@ test.describe.serial("not-connected app page", () => {
expect(appConns[0].status).not.toBe("archived");
});
- test("connected app page redirects from the app route and its row says Open", async ({ page }) => {
+ test("draft app connection stays on provider setup until setup finishes", async ({ page }) => {
await page.goto(`/${seed.prefix}/apps/app/${applicationId}`);
- await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/${connectionId}/setup$`), { timeout: 20_000 });
+ await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${applicationId}/setup$`), { timeout: 20_000 });
+ await expect(page.getByText("Not connected", { exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible();
await page.goto(`/${seed.prefix}/apps/connections`);
const row = page.locator("tbody tr", { hasText: "Bla" });
await expect(row).toBeVisible({ timeout: 30_000 });
- await expect(row.getByRole("button", { name: /Open|Review/ })).toBeVisible();
+ await expect(row.getByRole("button", { name: "Connect" })).toBeVisible();
await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true });
});
diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts
index 073ab69c71b..fc0697849a7 100644
--- a/tests/e2e/applications-crud.spec.ts
+++ b/tests/e2e/applications-crud.spec.ts
@@ -88,7 +88,7 @@ test.describe.serial("applications lifecycle", () => {
// background health sweep then probes the connection endpoint. The test
// endpoint is an unreachable fixture URL, so the probe fails and the pill
// becomes "Needs attention" and the action becomes "Reconnect". Both are
- // connected states that navigate to the same connection detail. This test
+ // connected states that navigate to the same provider setup page. This test
// proves the connected-vs-not-connected split, not the transient health
// label, so accept either connected state instead of the racy exact label.
// The pill is derived from two react-query fetches (applications +
@@ -107,11 +107,17 @@ test.describe.serial("applications lifecycle", () => {
await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-list.png`, fullPage: true });
await connectedRow.getByRole("button", { name: /^(Open|Reconnect)$/ }).click();
- await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/${connected.id}`), { timeout: 20_000 });
+ await expect(page).toHaveURL(
+ new RegExp(`/${seed.prefix}/apps/app/${connected.applicationId}/setup$`),
+ { timeout: 20_000 },
+ );
await gotoApps(page, seed.prefix);
await notConnectedRow.getByRole("button", { name: "Connect" }).click();
- await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${notConnected.id}`), { timeout: 20_000 });
+ await expect(page).toHaveURL(
+ new RegExp(`/${seed.prefix}/apps/app/${notConnected.id}/setup$`),
+ { timeout: 20_000 },
+ );
});
test("connected app detail supports pause, rename, and removal", async ({ page, request }) => {
diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts
index a49fe03b952..47692aec3dd 100644
--- a/ui/src/api/tools.ts
+++ b/ui/src/api/tools.ts
@@ -248,6 +248,7 @@ export const toolsApi = {
finishApp: (companyId: string, connectionId: string, input: {
enabledCatalogEntryIds: string[];
askFirstCatalogEntryIds: string[];
+ reviewedCatalogEntryIds?: string[];
access: "all_agents" | { agentIds: string[] };
}) =>
api.post(
diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx
index db0cb79dfb9..dd4da073b29 100644
--- a/ui/src/components/AppConnectionSidebar.test.tsx
+++ b/ui/src/components/AppConnectionSidebar.test.tsx
@@ -171,7 +171,7 @@ describe("AppConnectionSidebar", () => {
expect(container.textContent).toContain("GitHub");
expect(container.querySelectorAll("[data-to]").length).toBe(6);
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true }));
- expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 5, badgeTone: "danger" }));
+ expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 3, badgeTone: "danger" }));
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Permissions", end: true }));
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/test", label: "Test", end: true }));
expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/activity", label: "Activity", end: true }));
diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx
index 406ce39b034..a356a109908 100644
--- a/ui/src/components/AppConnectionSidebar.tsx
+++ b/ui/src/components/AppConnectionSidebar.tsx
@@ -77,7 +77,8 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
? attentionQuery.data?.apps.find((app) => app.connection.id === reviewConnectionId)
: null;
const reviewCount =
- (attentionItem?.pendingActionRequestCount ?? 0) + (attentionItem?.quarantinedCatalogEntryCount ?? 0);
+ (attentionItem?.pendingActionRequestCount ?? 0) +
+ ((attentionItem?.quarantinedCatalogEntryCount ?? 0) > 0 ? 1 : 0);
return (
diff --git a/ui/src/components/AppsSidebar.test.tsx b/ui/src/components/AppsSidebar.test.tsx
index 73b6d09f51c..ff0c9e403b4 100644
--- a/ui/src/components/AppsSidebar.test.tsx
+++ b/ui/src/components/AppsSidebar.test.tsx
@@ -126,7 +126,7 @@ describe("AppsSidebar", () => {
// "Run your own" / "Paste a config" moved to the Connect-an-app page (PAP-10922);
// assert their absence at the item level below.
- // Three peer consumer doors: Browse (store) · Connections · Review (PAP-13254).
+ // Consumer doors stay above the Developer boundary.
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
);
@@ -136,6 +136,8 @@ describe("AppsSidebar", () => {
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/review", label: "Review" }),
);
+ const sidebarText = container.textContent ?? "";
+ expect(sidebarText.indexOf("Connections")).toBeGreaterThan(sidebarText.indexOf("Developer"));
// "Needs attention" is no longer a top-level door — it folds into Connections.
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ label: "Needs attention" }),
diff --git a/ui/src/components/AppsSidebar.tsx b/ui/src/components/AppsSidebar.tsx
index 1b56b7f2a4d..f339f8e9bc6 100644
--- a/ui/src/components/AppsSidebar.tsx
+++ b/ui/src/components/AppsSidebar.tsx
@@ -14,12 +14,11 @@ import { SidebarNavItem } from "./SidebarNavItem";
* Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA
* PAP-13254 / U3).
*
- * ← Back · APPS: Browse / Connections / Review (n)
- * DEVELOPER: Gateways / Profiles / Rules / Health / Activity
+ * ← Back · APPS: Browse / Review (n)
+ * DEVELOPER: Connections / Gateways / Profiles / Rules / Health / Activity
*
- * The three consumer doors are peers: "Browse" (the store — discover + add),
- * "Connections" (your connected tools + health), and "Review" (PAP-12371,
- * Finding B — decisions waiting on your OK, with a live pending count).
+ * "Browse" is the store and "Review" holds decisions waiting on the user's
+ * OK. Connection management lives with the Developer tools.
* "Needs attention" is no longer a door: health/error triage folds into
* Connections as a status filter + banner, so approvals are never buried
* behind an error label. The Developer section was folded in from the retired
@@ -72,7 +71,6 @@ export function AppsSidebar() {
-
+
{developerTabs.map((tab) => (
{
// Reserved Apps subroutes are not connection ids. They must keep the
// top-level Apps sidebar, never mount a detail sidebar for a phantom app.
- it.each(["browse", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
+ it.each(["browse", "connections", "review"])("keeps the Apps sidebar on the %s surface", async (route) => {
currentPathname = `/PAP/apps/${route}`;
const root = createRoot(container);
const queryClient = new QueryClient({
diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx
index 2d0fb7ea0f0..4ba5d97589e 100644
--- a/ui/src/components/Layout.tsx
+++ b/ui/src/components/Layout.tsx
@@ -59,7 +59,16 @@ function getCompanyPathSegments(pathname: string, companyPrefix: string | undefi
return segments.slice(1);
}
-const RESERVED_APP_SUBPATHS = new Set(["browse", "connect", "review", "attention", "gateways", "advanced", "app"]);
+const RESERVED_APP_SUBPATHS = new Set([
+ "browse",
+ "connections",
+ "connect",
+ "review",
+ "attention",
+ "gateways",
+ "advanced",
+ "app",
+]);
function isSkillsStoreRoute(pathname: string, companyPrefix: string | undefined) {
const segments = pathname.split("/").filter(Boolean);
diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx
index 5125be35c8e..c009b0d2a61 100644
--- a/ui/src/pages/apps/AppDetail.test.tsx
+++ b/ui/src/pages/apps/AppDetail.test.tsx
@@ -294,7 +294,7 @@ describe("AppDetail", () => {
it.each([
["setup", "Agents can use this app"],
- ["review", "1 new action to review"],
+ ["review", "Review 1 new action"],
["permissions", "Action permissions"],
["activity", "No activity yet."],
["advanced", "Technical details"],
@@ -326,20 +326,72 @@ describe("AppDetail", () => {
expect(container.textContent).not.toContain("zapier-secret");
});
- it("shows new quarantined actions on the review tab instead of an empty state", async () => {
+ it("reviews quarantined actions as one toggle list and saves allowed and blocked choices together", async () => {
mockParams.tab = "review";
+ listCatalogMock.mockResolvedValue({
+ catalog: [
+ catalogEntry(),
+ catalogEntry({
+ id: "catalog-write",
+ toolName: "write_issue",
+ title: "Write issue",
+ isReadOnly: false,
+ }),
+ catalogEntry({
+ id: "catalog-quarantined-allow",
+ toolName: "delete_repo",
+ title: "Delete repo",
+ status: "quarantined",
+ isReadOnly: false,
+ }),
+ catalogEntry({
+ id: "catalog-quarantined-block",
+ toolName: "archive_repo",
+ title: "Archive repo",
+ status: "quarantined",
+ isReadOnly: false,
+ }),
+ ],
+ });
await renderAppDetail();
- expect(container.textContent).toContain("1 new action to review");
+ expect(container.textContent).toContain("Review 2 new actions");
+ expect(container.textContent).toContain("Delete repo");
+ expect(container.textContent).toContain("Archive repo");
+ expect(container.textContent).not.toContain("Nothing is waiting for your OK right now.");
+
+ const allowToggle = container.querySelector(
+ 'button[role="switch"][aria-label="Delete repo allowed"]',
+ );
+ expect(allowToggle?.getAttribute("aria-checked")).toBe("false");
+ await act(async () => {
+ allowToggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await flushReact();
+
await act(async () => {
Array.from(container.querySelectorAll("button"))
- .find((button) => button.textContent?.trim() === "Review")
+ .find((button) => button.textContent?.trim() === "Save choices")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
- expect(container.textContent).toContain("Delete repo");
- expect(container.textContent).not.toContain("Nothing is waiting for your OK right now.");
+
+ expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
+ enabledCatalogEntryIds: expect.arrayContaining([
+ "catalog-read",
+ "catalog-write",
+ "catalog-quarantined-allow",
+ ]),
+ askFirstCatalogEntryIds: ["catalog-write"],
+ reviewedCatalogEntryIds: expect.arrayContaining([
+ "catalog-quarantined-allow",
+ "catalog-quarantined-block",
+ ]),
+ access: "all_agents",
+ });
+ const finishInput = finishAppMock.mock.calls.at(-1)?.[2] as { enabledCatalogEntryIds: string[] };
+ expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block");
});
it("keeps setup focused on description and lifecycle", async () => {
@@ -476,7 +528,7 @@ describe("AppDetail", () => {
expect(container.textContent).toContain("Can make changes");
expect(container.textContent).toContain("Read repo");
expect(container.textContent).toContain("Write issue");
- expect(container.textContent).toContain("1 new action to review");
+ expect(container.textContent).toContain("Review 1 new action");
const readSelect = container.querySelector('select[aria-label="Read repo permission"]');
const writeSelect = container.querySelector('select[aria-label="Write issue permission"]');
expect(readSelect?.value).toBe("allowed");
diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx
index 6b97afe9102..5dff43b1c23 100644
--- a/ui/src/pages/apps/AppDetail.tsx
+++ b/ui/src/pages/apps/AppDetail.tsx
@@ -159,10 +159,16 @@ export function AppDetail() {
const [pending, setPending] = useState(false);
const persist = useMutation({
- mutationFn: (next: { enabled: Set; askFirst: Set; access: AccessDraft }) =>
+ mutationFn: (next: {
+ enabled: Set;
+ askFirst: Set;
+ access: AccessDraft;
+ reviewed?: Set;
+ }) =>
toolsApi.finishApp(selectedCompanyId!, connectionId, {
enabledCatalogEntryIds: [...next.enabled],
askFirstCatalogEntryIds: [...next.askFirst].filter((id) => next.enabled.has(id)),
+ ...(next.reviewed ? { reviewedCatalogEntryIds: [...next.reviewed] } : {}),
access: next.access.mode === "all" ? "all_agents" : { agentIds: [...next.access.agentIds] },
}),
onMutate: () => setPending(true),
@@ -317,13 +323,26 @@ export function AppDetail() {
}),
});
- const apply = (mutate: { enabled?: Set; askFirst?: Set; access?: AccessDraft }) =>
+ const apply = (mutate: {
+ enabled?: Set;
+ askFirst?: Set;
+ access?: AccessDraft;
+ reviewed?: Set;
+ }) =>
persist.mutate({
enabled: mutate.enabled ?? new Set(enabledIds),
askFirst: mutate.askFirst ?? new Set(askFirstIds),
access: mutate.access ?? access,
+ reviewed: mutate.reviewed,
});
+ const reviewQuarantined = (allowedIds: string[]) => {
+ const quarantinedIds = new Set(quarantined.map((entry) => entry.id));
+ const nextEnabled = new Set([...enabledIds].filter((id) => !quarantinedIds.has(id)));
+ for (const id of allowedIds) nextEnabled.add(id);
+ apply({ enabled: nextEnabled, reviewed: quarantinedIds });
+ };
+
if (!connectionId || !activeTab) {
return ;
}
@@ -411,7 +430,7 @@ export function AppDetail() {
connectionId={connectionId}
quarantined={quarantined}
pending={pending}
- onTurnOnQuarantined={(ids) => apply({ enabled: addAll(new Set(enabledIds), ids) })}
+ onReviewQuarantined={reviewQuarantined}
/>
)}
{activeTab === "permissions" && (
@@ -432,7 +451,7 @@ export function AppDetail() {
onSaveInstall={(next) => persistInstall.mutate(next)}
onRefreshActions={() => refreshTools.mutate()}
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
- onTurnOnQuarantined={(ids) => apply({ enabled: addAll(new Set(enabledIds), ids) })}
+ onReviewQuarantined={reviewQuarantined}
/>
)}
{activeTab === "test" && (
@@ -624,12 +643,6 @@ function galleryEntryFor(
null;
}
-function addAll(set: Set, ids: string[]): Set {
- const next = new Set(set);
- for (const id of ids) next.add(id);
- return next;
-}
-
function actionPermissionMutation(
id: string,
next: "off" | "allowed" | "ask",
diff --git a/ui/src/pages/apps/AppNotConnected.test.tsx b/ui/src/pages/apps/AppNotConnected.test.tsx
index 38b2a5de1c4..684cb2283a2 100644
--- a/ui/src/pages/apps/AppNotConnected.test.tsx
+++ b/ui/src/pages/apps/AppNotConnected.test.tsx
@@ -194,6 +194,57 @@ describe("AppNotConnected", () => {
expect(navigateComponentMock).toHaveBeenCalledWith({ to: "/apps/conn-live/permissions", replace: true });
});
+ it("shows all existing provider connections before connecting another", async () => {
+ listApplicationsMock.mockResolvedValue({
+ applications: [
+ application({
+ id: "app-1",
+ applicationKey: "app-gallery:notion:one",
+ name: "Notion",
+ metadata: { sourceTemplateKey: "notion" },
+ }),
+ application({
+ id: "app-2",
+ applicationKey: "app-gallery:notion:two",
+ name: "Notion workspace",
+ metadata: { sourceTemplateKey: "notion" },
+ }),
+ ],
+ });
+ listConnectionsMock.mockResolvedValue({
+ connections: [
+ connection({ id: "conn-one", applicationId: "app-1", name: "Notion", status: "active" }),
+ connection({ id: "conn-two", applicationId: "app-2", name: "Notion team", status: "active" }),
+ ],
+ });
+
+ await renderPage();
+
+ expect(navigateComponentMock).not.toHaveBeenCalled();
+ expect(container.textContent).toContain("2 connected");
+ expect(container.textContent).toContain("Already connected to Notion");
+ expect(container.textContent).toContain("Notion team");
+ expect(container.textContent).toContain("Connect another");
+
+ const editRows = Array.from(container.querySelectorAll("button")).filter((button) =>
+ button.textContent?.includes("Edit"),
+ );
+ await act(async () => {
+ editRows[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-two/setup");
+
+ const connectAnother = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Connect another",
+ );
+ await act(async () => {
+ connectAnother?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ expect(mockNavigate).toHaveBeenCalledWith(
+ "/apps/connect?applicationId=app-1&name=Notion&new=1&source=notion",
+ );
+ });
+
it.each([
["setup", "Reconnect this app"],
["review", "Nothing is waiting for your OK right now."],
diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx
index 5429983dd43..d49da9cb48d 100644
--- a/ui/src/pages/apps/AppNotConnected.tsx
+++ b/ui/src/pages/apps/AppNotConnected.tsx
@@ -1,6 +1,11 @@
import { useEffect, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ToolConnection } from "@paperclipai/shared";
+import {
+ connectionDisplaySecondaryHint,
+ humanizeConnectionDisplayName,
+ isToolConnectionAttentionHealth,
+} from "@paperclipai/shared";
import { Navigate, useNavigate, useParams } from "@/lib/router";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
@@ -13,11 +18,13 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { AppLogo } from "./AppLogo";
import {
+ appApplicationSourceSlug,
appDefinitionLogoUrl,
appDefinitionName,
appDefinitionSlug,
type AppGalleryDisplayEntry,
} from "./app-definition-display";
+import { isMcpDirectOAuthConnectSlug } from "./app-connect-policy";
import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDetail";
import { ActivityPanel } from "./app-detail/ActivityPanel";
import { ReviewPanel } from "./app-detail/ReviewPanel";
@@ -52,11 +59,25 @@ export function AppNotConnected() {
() => (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId),
[applicationsQuery.data, applicationId],
);
+ const appSourceSlug = appApplicationSourceSlug(application);
+ const relatedApplicationIds = useMemo(() => {
+ if (!application) return new Set();
+ if (!appSourceSlug) return new Set([application.id]);
+ return new Set(
+ (applicationsQuery.data?.applications ?? [])
+ .filter((candidate) => appApplicationSourceSlug(candidate) === appSourceSlug)
+ .map((candidate) => candidate.id),
+ );
+ }, [application, applicationsQuery.data, appSourceSlug]);
const appConnections = useMemo(
- () => (connectionsQuery.data?.connections ?? []).filter((c) => c.applicationId === applicationId),
- [connectionsQuery.data, applicationId],
+ () => (connectionsQuery.data?.connections ?? []).filter((c) => relatedApplicationIds.has(c.applicationId)),
+ [connectionsQuery.data, relatedApplicationIds],
);
- const activeConnection = appConnections.find((c) => c.status !== "archived") ?? null;
+ const activeConnections = useMemo(
+ () => appConnections.filter((c) => c.status !== "archived" && c.status !== "draft"),
+ [appConnections],
+ );
+ const activeConnection = activeConnections[0] ?? null;
const previousConnection = useMemo(() => latestArchivedConnection(appConnections), [appConnections]);
const activityQuery = useQuery({
queryKey: queryKeys.tools.connectionActivity(previousConnection?.id ?? "__none__"),
@@ -123,35 +144,44 @@ export function AppNotConnected() {
);
}
- if (activeConnection) {
+ if (activeConnection && activeTab !== "setup") {
return ;
}
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
const logoUrl =
- (application.applicationKey
- ? appDefinitionLogoUrl(gallery.find((entry) => appDefinitionSlug(entry) === application.applicationKey))
+ (appSourceSlug
+ ? appDefinitionLogoUrl(gallery.find((entry) => appDefinitionSlug(entry) === appSourceSlug))
: undefined) ??
appDefinitionLogoUrl(
gallery.find((entry) => appDefinitionName(entry).toLowerCase() === application.name.toLowerCase()),
);
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
- const connectHref = reconnectHref({
+ const connectHref = newConnectionHref({
applicationId,
appName: application.name,
previousAddress,
+ sourceSlug: appSourceSlug,
});
return (
-
+
{activeTab === "setup" && (
navigate(connectHref)}
+ onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
/>
)}
{activeTab === "review" && (
@@ -215,10 +245,12 @@ function ApplicationHeader({
applicationName,
description,
logoUrl,
+ connectedCount,
}: {
applicationName: string;
description: string | null;
logoUrl: string | undefined;
+ connectedCount: number;
}) {
return (
@@ -316,7 +316,7 @@ function ActionsSection({
refreshPending,
focusId,
onSetPermission,
- onTurnOnQuarantined,
+ onReviewQuarantined,
onRefreshActions,
}: {
readOnly: ToolCatalogEntry[];
@@ -328,7 +328,7 @@ function ActionsSection({
refreshPending: boolean;
focusId?: string | null;
onSetPermission: (id: string, next: ActionPermission) => void;
- onTurnOnQuarantined: (ids: string[]) => void;
+ onReviewQuarantined: (enabledIds: string[]) => void;
onRefreshActions: () => void;
}) {
return (
@@ -359,11 +359,10 @@ function ActionsSection({
{quarantined.length > 0 && (
-
)}
diff --git a/ui/src/pages/apps/app-detail/ReviewPanel.tsx b/ui/src/pages/apps/app-detail/ReviewPanel.tsx
index 221b2819970..6684f0613a7 100644
--- a/ui/src/pages/apps/app-detail/ReviewPanel.tsx
+++ b/ui/src/pages/apps/app-detail/ReviewPanel.tsx
@@ -1,26 +1,25 @@
import { ReviewQueueCard } from "../ReviewQueueCard";
-import { QuarantinePill } from "./SetupPanel";
+import { QuarantinedActionsReview } from "./SetupPanel";
import type { AppDetailSectionProps } from "./types";
export function ReviewPanel({
connectionId,
quarantined = [],
pending = false,
- onTurnOnQuarantined,
+ onReviewQuarantined,
}: Pick &
Partial> & {
- onTurnOnQuarantined?: (ids: string[]) => void;
+ onReviewQuarantined?: (enabledIds: string[]) => void;
}) {
- const showsQuarantinedActions = quarantined.length > 0 && !!onTurnOnQuarantined;
+ const showsQuarantinedActions = quarantined.length > 0 && !!onReviewQuarantined;
return (
{showsQuarantinedActions ? (
-
) : null}
void;
+ onSubmit: (enabledIds: string[]) => void;
}) {
- const [open, setOpen] = useState(false);
+ const [enabledIds, setEnabledIds] = useState>(new Set());
+ const count = entries.length;
+ const selectedIds = entries.filter((entry) => enabledIds.has(entry.id)).map((entry) => entry.id);
return (
-
-
-
- {count} new {count === 1 ? "action" : "actions"} to review
+
+
+
+
+ Review {count} new {count === 1 ? "action" : "actions"}
+
+
+ Turn on the actions agents may use. Anything left off stays blocked when you save.
+
- setOpen((v) => !v)}>
- {open ? "Hide" : "Review"}
-
- onTurnOn(entries.map((e) => e.id))}>
- Turn on all
-
+ setEnabledIds(new Set(entries.map((entry) => entry.id)))}
+ >
+ Turn all on
+
+ setEnabledIds(new Set())}
+ >
+ Turn all off
+
-
- This app added actions since you set it up. They stay off until you turn them on.
-
- {open && (
-
- {entries.map((entry) => (
-
+
+ {entries.map((entry) => {
+ const enabled = enabledIds.has(entry.id);
+ const label = entry.title ?? entry.toolName;
+ return (
+
-
{entry.title ?? entry.toolName}
+
{label}
{entry.description && (
{entry.description}
)}
-
onTurnOn([entry.id])}>
- Turn on
-
+
{
+ setEnabledIds((current) => {
+ const updated = new Set(current);
+ if (next) updated.add(entry.id);
+ else updated.delete(entry.id);
+ return updated;
+ });
+ }}
+ />
- ))}
-
- )}
-
+ );
+ })}
+
+
+
+ {selectedIds.length} of {count} will be on
+
+ onSubmit(selectedIds)}>
+ {disabled ? "Saving…" : "Save choices"}
+
+
+
);
}
diff --git a/ui/storybook/stories/permitted-vs-installed.stories.tsx b/ui/storybook/stories/permitted-vs-installed.stories.tsx
index 89b3e3076d4..380b943fd8e 100644
--- a/ui/storybook/stories/permitted-vs-installed.stories.tsx
+++ b/ui/storybook/stories/permitted-vs-installed.stories.tsx
@@ -119,7 +119,7 @@ function PanelHarness({ access, install }: { access: AccessDraft; install: Insta
onSaveAccess={() => {}}
onSaveInstall={setState}
onSetActionPermission={() => {}}
- onTurnOnQuarantined={() => {}}
+ onReviewQuarantined={() => {}}
onRefreshActions={() => {}}
/>
From 6b7e0814a08b228a2d9c685e46fa8ef0e3569c11 Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Fri, 7 Aug 2026 14:29:58 -0700
Subject: [PATCH 008/201] feat(acp): stream Daytona sandbox agent output and
remove the host output poll (#11049)
## Thinking Path
> - Paperclip is the open source app that manages AI agents for work
> - Sandbox providers let agents run in remote and isolated environments
> - Daytona session commands need a path that sends agent output to the
host without host polling
> - Host polling adds delay and repeats provider output work
> - This pull request adds typed execute.log notifications and a log
sink for incremental output
> - This pull request adds an optional ACP session stream with
final-result replay protection
> - The benefit is lower output delay while the default flags keep
current behavior unchanged
## Linked Issues or Issue Description
**Subsystem affected**
Cross-cutting. The change spans the plugin SDK, Daytona provider,
adapter utilities, and server execution services.
**Problem or motivation**
The Daytona ACP bridge polls a host output file while an agent command
runs. This adds delay and can repeat work. The host also needs a safe
route for provider output chunks.
**Proposed solution**
Add a typed `execute.log` notification with host-issued invocation
correlation. Add an ordered log sink to the environment execute path.
Add an optional ACP session-log path that parses newline-delimited JSON
frames and removes the host output poll for that path.
**Alternatives considered**
Keep the output-file poll as the only path. This keeps the current
behavior but does not provide timely output. The new path stays behind
flags, so the existing path remains the default fallback.
**Roadmap alignment**
This change supports the shipped Cloud / Sandbox agents milestone in
`ROADMAP.md`, including Daytona support.
## What Changed
- Add the typed `execute.log` worker-to-host notification and
company-scoped host route.
- Add ordered `stdout` and `stderr` chunk delivery before the final
execute result.
- Add the Daytona session log sink and the optional ACP streamed session
path.
- Add monotonic frame handling so live and final output reach the host
once.
- Keep `useLogStream` and `streamAgentSessionOutput` off by default.
- Add unit and integration coverage for the notification, execution
target, runtime, and Daytona paths.
## Verification
- Run adapter-utils tests: 445 tests pass locally.
- Run server environment tests: 73 tests pass locally.
- Run Daytona plugin tests: 131 tests pass locally.
- Run TypeScript checks for shared, adapter-utils, and server.
- Review the pull request checks after GitHub completes them.
- All required GitHub checks pass on the current head.
## Risks
The new paths change output delivery only when a feature flag enables
them. The final execute result remains available for parsing and
fallback. The main risk is a provider stream or frame-order error; the
final-result parser limits that risk.
## Model Used
OpenAI Codex, GPT-5, tool use and code execution. The runtime did not
supply a context-window value.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
No operator documentation change applies because both new flags remain
disabled by default.
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
.../adapter-utils/src/acpx-engine/execute.ts | 8 +
.../src/command-managed-runtime.ts | 21 ++
.../src/execution-target-sandbox.test.ts | 338 ++++++++++++++++++
.../adapter-utils/src/execution-target.ts | 290 ++++++++++++---
.../src/sandbox-callback-bridge.ts | 7 +
.../daytona/src/plugin.test.ts | 47 +++
.../sandbox-providers/daytona/src/plugin.ts | 28 +-
packages/plugins/sdk/src/protocol.ts | 23 ++
packages/plugins/sdk/src/testing.ts | 5 +
packages/plugins/sdk/src/types.ts | 29 ++
packages/plugins/sdk/src/worker-rpc-host.ts | 13 +
.../plugins/sdk/tests/worker-rpc-host.test.ts | 125 +++++++
packages/shared/src/types/environment.ts | 10 +
.../environment-execution-target.test.ts | 312 ++++++++++++++++
.../src/__tests__/environment-routes.test.ts | 58 +++
.../src/__tests__/environment-runtime.test.ts | 8 +-
.../fixtures/plugin-worker-execute-log.cjs | 117 ++++++
.../__tests__/plugin-worker-manager.test.ts | 275 ++++++++++++++
server/src/services/environment-config.ts | 30 +-
.../services/environment-execution-target.ts | 85 ++++-
server/src/services/environment-runtime.ts | 28 +-
server/src/services/plugin-worker-manager.ts | 285 ++++++++++++++-
22 files changed, 2075 insertions(+), 67 deletions(-)
create mode 100644 server/src/__tests__/fixtures/plugin-worker-execute-log.cjs
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
index a43c2543e1e..9a5a613a63c 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
@@ -1692,6 +1692,13 @@ async function buildRuntime(input: {
executionTarget.transport === "sandbox" &&
Boolean(executionTarget.runner) &&
Boolean(agentCommandShell);
+ // Stream the agent output through the persistent session log stream instead of
+ // the host output-file poll. Default OFF; an operator opts a sandbox
+ // environment in through the environment config.
+ const streamAgentSessionOutput =
+ executionTarget?.kind === "remote" &&
+ executionTarget.transport === "sandbox" &&
+ executionTarget.streamAgentSessionOutput === true;
// The ACP `session/new` cwd and every cwd-keyed session-state site
// (fingerprint, compat, persist, ensureSession, error) bind to THIS single
// value so a warm/resumable session created with the in-sandbox cwd is reused
@@ -2004,6 +2011,7 @@ async function buildRuntime(input: {
onLog: input.ctx.onLog,
getRuntimeParentContext: input.getRuntimeParentContext,
runtimeSpan: input.runtimeSpan,
+ streamOutputViaSession: streamAgentSessionOutput,
}),
concurrentBridgeStepMetrics,
);
diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts
index 823766ee138..5688b4f194b 100644
--- a/packages/adapter-utils/src/command-managed-runtime.ts
+++ b/packages/adapter-utils/src/command-managed-runtime.ts
@@ -34,6 +34,27 @@ export interface CommandManagedRuntimeRunner {
timeoutMs?: number;
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise;
onSpawn?: (meta: { pid: number; startedAt: string }) => Promise;
+ /**
+ * Run this command through the lease's persistent session even when no run
+ * step is active. A sandbox provider opens the session on the first
+ * non-bypassed command; the ACP process session bridge sets this so the
+ * long-lived agent command streams its output through the session log
+ * stream. The default keeps the context-based session selection.
+ */
+ useSession?: boolean;
+ /**
+ * Run this command outside the lease's persistent session even when a run
+ * step is active. The persistent session is a single serialized shell. In
+ * streamed mode the agent runs as one long-lived foreground command that
+ * holds the session for the whole run. The bridge control-plane execs
+ * (input delivery, output read, callback relay, and the queue/setup
+ * bookkeeping) must run concurrently with the agent, so they run as
+ * independent one-shot commands. On the session they queue behind the agent
+ * command that never returns — a permanent deadlock. An explicit bypass
+ * always wins over the context-based session selection and over
+ * `useSession`. The default keeps the context-based session selection.
+ */
+ bypassSession?: boolean;
}): Promise;
/**
* Optional native inbound file transfer. Present only when the sandbox
diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts
index d4425a6ddfc..db7eeeda206 100644
--- a/packages/adapter-utils/src/execution-target-sandbox.test.ts
+++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts
@@ -901,6 +901,344 @@ describe("sandbox adapter execution targets", () => {
}
});
+ describe("streamed output (streamOutputViaSession)", () => {
+ it("bridges bidirectional sessions when the wrapper streams output to stdout", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-echo-"));
+ cleanupDirs.push(rootDir);
+ const childPath = path.join(rootDir, "echo-acp-child.mjs");
+ await writeFile(
+ childPath,
+ [
+ "process.stdin.on('data', (chunk) => {",
+ " process.stdout.write('out:' + chunk.toString());",
+ " process.stderr.write('err:' + chunk.toString());",
+ "});",
+ ].join("\n"),
+ "utf8",
+ );
+ const target: AdapterSandboxExecutionTarget = {
+ kind: "remote",
+ transport: "sandbox",
+ providerKey: "local-test",
+ remoteCwd: rootDir,
+ timeoutMs: 30_000,
+ runner: createLocalSandboxRunner(),
+ };
+
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stream-echo",
+ target,
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
+ adapterKey: "acpx",
+ command: process.execPath,
+ args: [childPath],
+ cwd: rootDir,
+ env: {},
+ timeoutSec: 5,
+ onLog: async () => {},
+ streamOutputViaSession: true,
+ });
+ expect(bridge).not.toBeNull();
+
+ try {
+ const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
+ expect(result.code).toBe(0);
+ expect(result.stdout).toBe("out:hello\n");
+ expect(result.stderr).toBe("err:hello\n");
+ } finally {
+ await bridge?.stop();
+ }
+ });
+
+ it("buffers streamed output until the local proxy connects", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-buffer-"));
+ cleanupDirs.push(rootDir);
+ const childPath = path.join(rootDir, "fast-stream-child.mjs");
+ await writeFile(
+ childPath,
+ [
+ "process.stdout.write('early-out\\n');",
+ "process.stderr.write('early-err\\n');",
+ "setTimeout(() => process.exit(0), 20);",
+ ].join("\n"),
+ "utf8",
+ );
+ const target: AdapterSandboxExecutionTarget = {
+ kind: "remote",
+ transport: "sandbox",
+ providerKey: "local-test",
+ remoteCwd: rootDir,
+ timeoutMs: 30_000,
+ runner: createLocalSandboxRunner(),
+ };
+
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stream-buffer",
+ target,
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
+ adapterKey: "acpx",
+ command: process.execPath,
+ args: [childPath],
+ cwd: rootDir,
+ env: {},
+ timeoutSec: 5,
+ onLog: async () => {},
+ streamOutputViaSession: true,
+ });
+ expect(bridge).not.toBeNull();
+
+ try {
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ const result = await runProxyWithInput(bridge!.agentCommand, "");
+ expect(result.code).toBe(0);
+ // The seq guard delivers the early output exactly once even though the
+ // live stream and the terminal result both carry it.
+ expect(result.stdout).toBe("early-out\n");
+ expect(result.stderr).toBe("early-err\n");
+ } finally {
+ await bridge?.stop();
+ }
+ });
+
+ it("delivers full streamed output when the sandbox child exits immediately", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-fast-exit-"));
+ cleanupDirs.push(rootDir);
+ const childPath = path.join(rootDir, "instant-stream-child.mjs");
+ await writeFile(
+ childPath,
+ [
+ "process.stdout.write('final-out\\n');",
+ "process.stderr.write('final-err\\n');",
+ ].join("\n"),
+ "utf8",
+ );
+ const target: AdapterSandboxExecutionTarget = {
+ kind: "remote",
+ transport: "sandbox",
+ providerKey: "local-test",
+ remoteCwd: rootDir,
+ timeoutMs: 30_000,
+ runner: createLocalSandboxRunner(),
+ };
+
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stream-fast-exit",
+ target,
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
+ adapterKey: "acpx",
+ command: process.execPath,
+ args: [childPath],
+ cwd: rootDir,
+ env: {},
+ timeoutSec: 5,
+ onLog: async () => {},
+ streamOutputViaSession: true,
+ });
+ expect(bridge).not.toBeNull();
+
+ try {
+ const result = await runProxyWithInput(bridge!.agentCommand, "");
+ expect(result.code).toBe(0);
+ expect(result.stdout).toBe("final-out\n");
+ expect(result.stderr).toBe("final-err\n");
+ } finally {
+ await bridge?.stop();
+ }
+ });
+
+ it("streams live output before the child exits and never writes output event files", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-live-"));
+ cleanupDirs.push(rootDir);
+ const childPath = path.join(rootDir, "live-stream-child.mjs");
+ await writeFile(
+ childPath,
+ [
+ "process.stdin.setEncoding('utf8');",
+ "process.stdin.on('data', (chunk) => {",
+ " if (chunk.includes('ping')) {",
+ " process.stdout.write('delta:ping\\n');",
+ " process.stderr.write('trace:ping\\n');",
+ " }",
+ " if (chunk.includes('finish')) process.exit(0);",
+ "});",
+ "process.stdin.resume();",
+ ].join("\n"),
+ "utf8",
+ );
+ const target: AdapterSandboxExecutionTarget = {
+ kind: "remote",
+ transport: "sandbox",
+ providerKey: "local-test",
+ remoteCwd: rootDir,
+ timeoutMs: 30_000,
+ runner: createLocalSandboxRunner(),
+ };
+
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stream-live",
+ target,
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
+ adapterKey: "acpx",
+ command: process.execPath,
+ args: [childPath],
+ cwd: rootDir,
+ env: {},
+ timeoutSec: 5,
+ onLog: async () => {},
+ streamOutputViaSession: true,
+ });
+ expect(bridge).not.toBeNull();
+
+ const child = spawn(bridge!.agentCommand, [], { stdio: ["pipe", "pipe", "pipe"] });
+ let stdout = "";
+ let stderr = "";
+ let exited = false;
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ const exitPromise = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ child.kill("SIGKILL");
+ reject(new Error("Timed out waiting for streamed process session proxy."));
+ }, 5000);
+ child.on("error", (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+ child.on("exit", (exitCode) => {
+ exited = true;
+ clearTimeout(timeout);
+ resolve(exitCode);
+ });
+ });
+
+ try {
+ child.stdin.write("ping\n");
+ await waitForCondition(
+ () => stdout.includes("delta:ping\n") && stderr.includes("trace:ping\n"),
+ "Timed out waiting for live streamed process session output.",
+ 3000,
+ );
+ expect(exited).toBe(false);
+
+ child.stdin.end("finish\n");
+ await expect(exitPromise).resolves.toBe(0);
+
+ // The streamed path uses the stdout wrapper, not the output-file poll, so
+ // no `events` directory is ever created under the session runtime tree.
+ const hasEventsDir = await readdir(
+ path.posix.join(rootDir, ".paperclip-runtime", "acpx", "process-sessions"),
+ { withFileTypes: true, recursive: true },
+ )
+ .then((entries) => entries.some((entry) => entry.isDirectory() && entry.name === "events"))
+ .catch(() => false);
+ expect(hasEventsDir).toBe(false);
+ } finally {
+ if (!exited) {
+ child.kill("SIGKILL");
+ await exitPromise.catch(() => undefined);
+ }
+ await bridge?.stop();
+ }
+ });
+
+ it("keeps the agent command on the persistent session and forces bridge control execs off it", async () => {
+ // Regression guard for the streamed-mode startup deadlock. The persistent
+ // session is one serialized shell. In streamed mode the agent runs as a
+ // long-lived foreground session command that holds the session for the
+ // whole run. The bridge control-plane execs (script sync, stdin delivery,
+ // teardown) must run concurrently with the agent, so each must force
+ // itself off the session. On the session they queue behind the agent
+ // command that never returns, and the first handshake write never drains.
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-process-session-stream-isolation-"));
+ cleanupDirs.push(rootDir);
+ const childPath = path.join(rootDir, "echo-acp-child.mjs");
+ await writeFile(
+ childPath,
+ [
+ "process.stdin.on('data', (chunk) => {",
+ " process.stdout.write('out:' + chunk.toString());",
+ "});",
+ ].join("\n"),
+ "utf8",
+ );
+
+ const delegate = createLocalSandboxRunner();
+ const execs: Array<{ useSession?: boolean; bypassSession?: boolean; script: string }> = [];
+ const runner = {
+ execute: vi.fn(
+ async (
+ input: Parameters[0] & {
+ useSession?: boolean;
+ bypassSession?: boolean;
+ },
+ ) => {
+ execs.push({
+ useSession: input.useSession,
+ bypassSession: input.bypassSession,
+ script: input.args?.[1] ?? "",
+ });
+ return delegate.execute(input);
+ },
+ ),
+ };
+ const target: AdapterSandboxExecutionTarget = {
+ kind: "remote",
+ transport: "sandbox",
+ providerKey: "local-test",
+ remoteCwd: rootDir,
+ timeoutMs: 30_000,
+ runner,
+ };
+
+ const bridge = await startAdapterExecutionTargetProcessSessionBridge({
+ runId: "run-stream-isolation",
+ target,
+ runtimeRootDir: path.posix.join(rootDir, ".paperclip-runtime", "acpx"),
+ adapterKey: "acpx",
+ command: process.execPath,
+ args: [childPath],
+ cwd: rootDir,
+ env: {},
+ timeoutSec: 5,
+ onLog: async () => {},
+ streamOutputViaSession: true,
+ });
+ expect(bridge).not.toBeNull();
+
+ try {
+ // Round-trip one input so a stdin-delivery control exec runs and gets
+ // recorded before the assertions below.
+ const result = await runProxyWithInput(bridge!.agentCommand, "hello\n");
+ expect(result.stdout).toBe("out:hello\n");
+
+ // Exactly one exec runs on the persistent session: the long-lived agent
+ // command. It streams its output through the session log stream, so it
+ // must not also bypass the session.
+ const sessionExecs = execs.filter((exec) => exec.useSession === true);
+ expect(sessionExecs).toHaveLength(1);
+ expect(sessionExecs[0]!.bypassSession).not.toBe(true);
+ expect(sessionExecs[0]!.script).toContain("node ");
+
+ // Every other exec is bridge control-plane plumbing. Each must force
+ // itself off the persistent session so it never queues behind the agent
+ // command that holds it.
+ const controlExecs = execs.filter((exec) => exec.useSession !== true);
+ expect(controlExecs.length).toBeGreaterThan(0);
+ for (const exec of controlExecs) {
+ expect(exec.bypassSession).toBe(true);
+ }
+ } finally {
+ await bridge?.stop();
+ }
+ });
+ });
+
it("applies the remote sandbox fallback when adapter timeoutSec is unset", () => {
const sandboxTarget: AdapterSandboxExecutionTarget = {
kind: "remote",
diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts
index 5e7aa5719f2..90989c6f9f8 100644
--- a/packages/adapter-utils/src/execution-target.ts
+++ b/packages/adapter-utils/src/execution-target.ts
@@ -106,6 +106,13 @@ export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWor
* set to `false` to explicitly opt out back to batch-at-end delivery.
*/
streamRunLogs?: boolean | null;
+ /**
+ * Stream the interactive ACP agent output through the persistent session log
+ * stream instead of the host-side output-file poll. The process session
+ * bridge runs the agent as one long-lived session command and reads its
+ * output frames from the stream. Default OFF: the bridge keeps the poll path.
+ */
+ streamAgentSessionOutput?: boolean | null;
}
export type AdapterExecutionTarget =
@@ -1278,6 +1285,10 @@ async function readBridgeForwardResponseBody(response: Response, maxBodyBytes: n
const PROCESS_SESSION_PROXY_SCRIPT = "paperclip-process-session-proxy.mjs";
const PROCESS_SESSION_REMOTE_SCRIPT = "paperclip-process-session-remote.mjs";
+// The streamed variant writes its output frames to stdout, so it rides a
+// separate remote path. A sandbox can hold both scripts without the content
+// hash-skip gate thrashing when a run switches output mode.
+const PROCESS_SESSION_REMOTE_STREAM_SCRIPT = "paperclip-process-session-remote-stream.mjs";
const PROCESS_SESSION_AUTH_TIMEOUT_MS = 5_000;
function jsonLine(value: unknown): string {
@@ -1309,13 +1320,14 @@ async function syncProcessSessionRemoteScript(input: {
remoteScriptPath: string;
timeoutMs?: number | null;
shellCommand?: "bash" | "sh" | null;
+ outputToStdout?: boolean;
}): Promise<{ uploaded: boolean }> {
const { uploaded } = await syncRemoteTextFileWithHashSkip({
runner: input.runner,
remoteCwd: input.remoteCwd,
remoteDir: input.remoteScriptDir,
remotePath: input.remoteScriptPath,
- body: getProcessSessionRemoteSource(),
+ body: getProcessSessionRemoteSource({ outputToStdout: input.outputToStdout === true }),
label: "Process session remote script",
action: "sync process session remote script",
lockDir: path.posix.join(input.remoteScriptDir, ".paperclip-process-session-script.lock"),
@@ -1391,6 +1403,11 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
// group under one wrapper span. When it is absent, the work runs under the run
// parent with no wrapper span, exactly like the earlier behavior.
runtimeSpan?: RuntimeSpanRunner;
+ // Stream the agent output through the persistent session log stream instead of
+ // the host output-file poll. When true, the bridge runs the wrapper as one
+ // long-lived session command and reads its stdout frames from the stream, and
+ // it does not start the 100 ms poll. Default OFF: the bridge keeps the poll.
+ streamOutputViaSession?: boolean;
}): Promise {
if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") {
return null;
@@ -1420,7 +1437,14 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
const sessionDir = path.posix.join(bridgeRuntimeDir, sessionId);
const stdinDir = path.posix.join(sessionDir, "stdin");
const eventsDir = path.posix.join(sessionDir, "events");
- const remoteScriptPath = path.posix.join(bridgeRuntimeDir, PROCESS_SESSION_REMOTE_SCRIPT);
+ // The streamed wrapper writes its frames to stdout and rides a separate remote
+ // path, so a warm sandbox can hold both wrapper scripts without the content
+ // hash-skip gate thrashing when a run switches output mode.
+ const streamOutput = input.streamOutputViaSession === true;
+ const remoteScriptPath = path.posix.join(
+ bridgeRuntimeDir,
+ streamOutput ? PROCESS_SESSION_REMOTE_STREAM_SCRIPT : PROCESS_SESSION_REMOTE_SCRIPT,
+ );
const client = createCommandManagedSandboxCallbackBridgeQueueClient({
runner,
remoteCwd: target.remoteCwd,
@@ -1438,6 +1462,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
remoteScriptPath,
timeoutMs,
shellCommand,
+ outputToStdout: streamOutput,
});
// Resolve the launch env AFTER the env-independent setup above, so a caller
@@ -1451,26 +1476,34 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
env: sanitizeRemoteExecutionEnv(launchEnv),
}), "utf8").toString("base64");
- await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`);
- const startResult = await runner.execute({
- command: shellCommand,
- args: shellCommandArgs(
- [
- `mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`,
- `PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` +
- `PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` +
- `nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`,
- "printf '%s\\n' \"$!\"",
- ].join("\n"),
- ),
- cwd: target.remoteCwd,
- env: {
- PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
- },
- timeoutMs,
- });
- if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) {
- throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`);
+ // Legacy poll path: background the wrapper with `nohup` and read its output
+ // event files with the host poll below. The streamed path launches the wrapper
+ // as one foreground session command further down instead, so skip this.
+ if (!streamOutput) {
+ await onLog("stdout", `[paperclip] Starting ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`);
+ const startResult = await runner.execute({
+ command: shellCommand,
+ args: shellCommandArgs(
+ [
+ `mkdir -p ${shellQuote(stdinDir)} ${shellQuote(eventsDir)}`,
+ `PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` +
+ `PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` +
+ `nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`,
+ "printf '%s\\n' \"$!\"",
+ ].join("\n"),
+ ),
+ cwd: target.remoteCwd,
+ env: {
+ PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
+ },
+ timeoutMs,
+ // The wrapper launch is bridge plumbing. Keep it off the persistent
+ // session so it never queues behind an in-run session command.
+ bypassSession: true,
+ });
+ if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) {
+ throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`);
+ }
}
let socket: net.Socket | null = null;
@@ -1640,7 +1673,105 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
const port = await waitForLocalServerListen(server);
const agentCommand = await writeProcessSessionProxyScript(proxyDir, port, token);
- schedulePoll();
+
+ if (streamOutput) {
+ // Streamed output path. Run the wrapper as one long-lived session command;
+ // its stdout carries newline-delimited JSON frames that reach the host
+ // through the provider session log stream. Deliver each frame exactly once
+ // by its monotonic `seq`, so a frame that arrives both live and in the final
+ // result is not repeated. There is no host output-file poll here.
+ let streamBuffer = "";
+ let lastSeq = 0;
+ let sawTerminal = false;
+ const deliverFrame = (frame: (typeof pendingRemoteEvents)[number] & { seq?: number }) => {
+ if (typeof frame.seq === "number") {
+ if (frame.seq <= lastSeq) return;
+ lastSeq = frame.seq;
+ }
+ if (frame.type === "exit" || frame.type === "error") sawTerminal = true;
+ deliverRemoteEvent(frame);
+ };
+ const parseFrameLine = (line: string) => {
+ if (!line.trim()) return;
+ let frame: (typeof pendingRemoteEvents)[number] & { seq?: number };
+ try {
+ frame = JSON.parse(line) as typeof frame;
+ } catch {
+ return;
+ }
+ deliverFrame(frame);
+ };
+ // Live delivery: buffer partial lines across stream chunks, deliver each
+ // complete frame line as it arrives.
+ const ingestStreamChunk = (text: string) => {
+ streamBuffer += text;
+ const split = splitJsonLines(streamBuffer);
+ streamBuffer = split.rest;
+ for (const line of split.lines) parseFrameLine(line);
+ };
+ // Terminal delivery (the defined fallback to the poll): the resolved result
+ // carries the full wrapper stdout even when the live stream degraded to the
+ // provider session-log poll. The text is complete and self-contained, so
+ // re-parse it on its own; the `seq` guard drops every frame the live stream
+ // already delivered. Drop any partial live line — its complete form is in the
+ // full text.
+ const ingestFinalText = (text: string) => {
+ streamBuffer = "";
+ for (const line of text.split(/\n/)) parseFrameLine(line);
+ };
+
+ const launchEnvForStream =
+ typeof input.env === "function" ? await input.env() : input.env;
+ const streamCommandPayload = Buffer.from(JSON.stringify({
+ command: input.command,
+ args: input.args,
+ cwd: input.cwd || target.remoteCwd,
+ env: sanitizeRemoteExecutionEnv(launchEnvForStream),
+ }), "utf8").toString("base64");
+ await onLog(
+ "stdout",
+ `[paperclip] Starting streamed ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`,
+ );
+ // Fire the long-lived command; do NOT await it here. `useSession` forces the
+ // persistent session so the provider streams the wrapper stdout back through
+ // `onLog`. On resolve, the terminal re-parse fills any frames the live stream
+ // missed; on reject, deliver one error frame so the local proxy fails loud.
+ void runner
+ .execute({
+ command: shellCommand,
+ args: shellCommandArgs(`node ${shellQuote(remoteScriptPath)}`),
+ cwd: target.remoteCwd,
+ env: {
+ PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
+ PAPERCLIP_PROCESS_SESSION_COMMAND_B64: streamCommandPayload,
+ PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
+ },
+ timeoutMs,
+ useSession: true,
+ onLog: async (stream, chunk) => {
+ if (stream === "stdout") ingestStreamChunk(chunk);
+ },
+ })
+ .then((result) => {
+ ingestFinalText(result.stdout);
+ if (!sawTerminal && !stopping) {
+ deliverRemoteEvent({
+ type: "exit",
+ code: typeof result.exitCode === "number" ? result.exitCode : null,
+ });
+ }
+ })
+ .catch((error) => {
+ if (!stopping) {
+ deliverRemoteEvent({
+ type: "error",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
+ });
+ } else {
+ schedulePoll();
+ }
return {
agentCommand,
@@ -1706,7 +1837,94 @@ socket.on("close", () => {
`;
}
-function getProcessSessionRemoteSource(): string {
+function getProcessSessionRemoteSource(input?: { outputToStdout?: boolean }): string {
+ return input?.outputToStdout === true
+ ? getProcessSessionRemoteStreamSource()
+ : getProcessSessionRemoteEventFileSource();
+}
+
+// The shared stdin drain. Both wrappers read newline-delimited stdin messages
+// from the stdin file queue and write them to the child, then end the child
+// stdin on `stdinEnd`. A write to a closed child stdin only emits an `error`
+// event, so the wrapper installs a no-op handler at the call site.
+const PROCESS_SESSION_STDIN_POLL_TAIL = `child.stdin.on("error", () => {});
+
+async function pollStdin() {
+ while (!stdinClosed) {
+ const entries = (await fs.readdir(stdinDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
+ for (const name of entries) {
+ const file = path.posix.join(stdinDir, name);
+ const raw = await fs.readFile(file, "utf8").catch(() => null);
+ await fs.rm(file, { force: true }).catch(() => undefined);
+ if (!raw) continue;
+ const message = JSON.parse(raw);
+ if (message.type === "stdin" && typeof message.data === "string") {
+ if (!stdinClosed) child.stdin.write(Buffer.from(message.data, "base64"));
+ } else if (message.type === "stdinEnd") {
+ stdinClosed = true;
+ child.stdin.end();
+ break;
+ }
+ }
+ if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+}
+
+void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) }));
+`;
+
+// Streamed variant: the wrapper writes each output frame as one newline-
+// delimited JSON line to its stdout. The host runs this wrapper as one
+// long-lived session command and reads the frames from the session log stream,
+// so there is no host output-file poll. Each frame carries a monotonic `seq`,
+// so the host delivers every frame exactly once whether it arrives live or in
+// the final result. The wrapper exits when the child closes, so the session
+// command settles and the session shell (the subshell wrap around it) survives.
+function getProcessSessionRemoteStreamSource(): string {
+ return `import { spawn } from "node:child_process";
+import { promises as fs } from "node:fs";
+import path from "node:path";
+
+const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;
+const commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64;
+if (!sessionDir || !commandPayload) throw new Error("Missing process session bridge env.");
+
+const stdinDir = path.posix.join(sessionDir, "stdin");
+let seq = 0;
+let stdinClosed = false;
+
+const config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8"));
+await fs.mkdir(stdinDir, { recursive: true });
+
+// One newline-delimited JSON frame per event. Node keeps process.stdout writes
+// ordered, and the base64 payload holds no newline, so each frame is one line.
+function writeEvent(event) {
+ seq += 1;
+ process.stdout.write(JSON.stringify({ seq, ...event }) + "\\n");
+}
+
+const child = spawn(config.command, Array.isArray(config.args) ? config.args : [], {
+ cwd: config.cwd || process.cwd(),
+ env: { ...process.env, ...(config.env || {}) },
+ stdio: ["pipe", "pipe", "pipe"],
+});
+
+child.stdout.on("data", (chunk) => writeEvent({ type: "data", stream: "stdout", data: Buffer.from(chunk).toString("base64") }));
+child.stderr.on("data", (chunk) => writeEvent({ type: "data", stream: "stderr", data: Buffer.from(chunk).toString("base64") }));
+child.on("error", (error) => writeEvent({ type: "error", message: error.message }));
+// "close" (not "exit") so stdout/stderr fully drain before the exit frame.
+// Stop the stdin poll and set the exit code, then let the event loop drain: a
+// natural exit flushes the stdout pipe, so the exit frame always lands.
+child.on("close", (code, signal) => {
+ writeEvent({ type: "exit", code, signal });
+ stdinClosed = true;
+ process.exitCode = typeof code === "number" ? code : 1;
+});
+
+${PROCESS_SESSION_STDIN_POLL_TAIL}`;
+}
+
+function getProcessSessionRemoteEventFileSource(): string {
return `import { spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import path from "node:path";
@@ -1750,29 +1968,7 @@ child.on("error", (error) => void writeEvent({ type: "error", message: error.mes
// the write chain then guarantees the exit file lands after every data file.
child.on("close", (code, signal) => void writeEvent({ type: "exit", code, signal }));
-async function pollStdin() {
- while (!stdinClosed) {
- const entries = (await fs.readdir(stdinDir).catch(() => [])).filter((name) => name.endsWith(".json")).sort();
- for (const name of entries) {
- const file = path.posix.join(stdinDir, name);
- const raw = await fs.readFile(file, "utf8").catch(() => null);
- await fs.rm(file, { force: true }).catch(() => undefined);
- if (!raw) continue;
- const message = JSON.parse(raw);
- if (message.type === "stdin" && typeof message.data === "string") {
- child.stdin.write(Buffer.from(message.data, "base64"));
- } else if (message.type === "stdinEnd") {
- stdinClosed = true;
- child.stdin.end();
- break;
- }
- }
- if (!stdinClosed) await new Promise((resolve) => setTimeout(resolve, 50));
- }
-}
-
-void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) }));
-`;
+${PROCESS_SESSION_STDIN_POLL_TAIL}`;
}
export async function startAdapterExecutionTargetPaperclipBridge(input: {
diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts
index bd5a83ab5f7..35ee47db702 100644
--- a/packages/adapter-utils/src/sandbox-callback-bridge.ts
+++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts
@@ -236,6 +236,13 @@ async function runShell(
},
timeoutMs,
stdin,
+ // Every command that rides this helper is bridge control-plane plumbing:
+ // input delivery, output read, callback relay, and queue/setup bookkeeping.
+ // It must run concurrently with the agent, so force it off the persistent
+ // session. In streamed mode the agent holds that single serialized session
+ // for the whole run; a control write on the same session queues behind the
+ // agent command that never returns — a permanent deadlock.
+ bypassSession: true,
});
}
diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts
index 7b2eb020c25..61d064961a5 100644
--- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts
+++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts
@@ -1668,6 +1668,53 @@ describe("Daytona sandbox provider plugin", () => {
expect(result).toMatchObject({ exitCode: 0, timedOut: false, stdout: "AAABBB", stderr: "EEEFFF" });
expect(sandbox.process.getSessionCommandLogs).toHaveBeenCalledTimes(2);
});
+
+ it("emits each new chunk to the host and drops a replayed prefix (test_log_stream_emits_execute_log_per_chunk)", async () => {
+ process.env.DAYTONA_API_KEY = "host-key";
+ const executionLog = vi.fn();
+ const restore = __setDaytonaPluginContextForTest(
+ { execution: { log: executionLog } } as unknown as PluginContext,
+ );
+ try {
+ const sandbox = createMockSandbox();
+ let attempt = 0;
+ sandbox.process.getSessionCommandLogs.mockImplementation(
+ async (
+ _sid: string,
+ _cmdId: string,
+ onStdout?: (chunk: string) => void,
+ onStderr?: (chunk: string) => void,
+ ) => {
+ attempt += 1;
+ if (attempt === 1) {
+ onStdout?.("AAA");
+ onStderr?.("EEE");
+ throw new Error("socket error");
+ }
+ // Reconnect replays the whole log from byte 0, then the new tail.
+ onStdout?.("AAA");
+ onStdout?.("BBB");
+ onStderr?.("EEE");
+ onStderr?.("FFF");
+ },
+ );
+ sandbox.process.getSessionCommand.mockResolvedValue({ id: "cmd-1", command: "", exitCode: 0 });
+ mockGet.mockResolvedValue(sandbox);
+
+ await plugin.definition.onEnvironmentExecute?.(streamExecParams());
+
+ // Each genuinely new chunk reaches the host exactly once. The replayed
+ // prefix ("AAA"/"EEE") on the reconnect is not re-emitted.
+ expect(executionLog.mock.calls).toEqual([
+ ["stdout", "AAA"],
+ ["stderr", "EEE"],
+ ["stdout", "BBB"],
+ ["stderr", "FFF"],
+ ]);
+ } finally {
+ restore();
+ }
+ });
});
});
diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts
index b760f79b342..9b8e1608982 100644
--- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts
+++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts
@@ -1508,13 +1508,16 @@ const MAX_SESSION_STREAM_RECONNECTS = 1;
// The buffer stores each new tail as a separate chunk and joins the chunks one
// time at read. It does not copy the earlier output on each append, so total
// buffering work stays linear in the output size, not quadratic.
-function createSessionStreamBuffer() {
+function createSessionStreamBuffer(
+ onNewTail?: (stream: "stdout" | "stderr", text: string) => void,
+) {
const streams = {
stdout: { chunks: [] as Buffer[], length: 0, connectionBytes: 0 },
stderr: { chunks: [] as Buffer[], length: 0, connectionBytes: 0 },
};
function append(
+ streamName: "stdout" | "stderr",
stream: { chunks: Buffer[]; length: number; connectionBytes: number },
chunk: string,
): void {
@@ -1531,11 +1534,16 @@ function createSessionStreamBuffer() {
const tail = start >= stream.length ? buf : buf.subarray(stream.length - start);
stream.chunks.push(tail);
stream.length += tail.length;
+ // Deliver only the genuinely new tail to the live sink, so a replayed
+ // prefix on a reconnect never reaches the host twice.
+ if (onNewTail && tail.length > 0) {
+ onNewTail(streamName, tail.toString("utf8"));
+ }
}
return {
- onStdout: (chunk: string) => append(streams.stdout, chunk),
- onStderr: (chunk: string) => append(streams.stderr, chunk),
+ onStdout: (chunk: string) => append("stdout", streams.stdout, chunk),
+ onStderr: (chunk: string) => append("stderr", streams.stderr, chunk),
// Reset the per-connection read cursors after a reconnect, so the replayed
// prefix drops against the already-delivered byte count.
resetConnectionCursors(): void {
@@ -1564,8 +1572,9 @@ async function runSessionLogStream(
sandbox: Sandbox,
sessionId: string,
commandId: string,
+ onNewTail?: (stream: "stdout" | "stderr", text: string) => void,
): Promise {
- const buffer = createSessionStreamBuffer();
+ const buffer = createSessionStreamBuffer(onNewTail);
let reconnects = 0;
while (true) {
try {
@@ -1668,7 +1677,16 @@ async function executeInSession(
// to the poll path below, because the command still runs to its exit on the
// server.
if (config.useLogStream) {
- const streamResult = await runSessionLogStream(sandbox, sessionId, commandId);
+ // Emit each genuinely new output chunk to the host during the active
+ // execute call. The host routes it to the runner log sink by the
+ // host-issued invocation id. This is a no-op when no plugin context is
+ // set (a direct test call) or when the host has no active execute route.
+ const streamResult = await runSessionLogStream(
+ sandbox,
+ sessionId,
+ commandId,
+ (stream, text) => pluginContext?.execution.log(stream, text),
+ );
if (streamResult.ok) {
const exitCode = await readSessionExitCode(sandbox, sessionId, commandId);
const durationMs = timingNow() - execStart;
diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts
index e14ba5fa26f..3fb8e8035a9 100644
--- a/packages/plugins/sdk/src/protocol.ts
+++ b/packages/plugins/sdk/src/protocol.ts
@@ -1927,6 +1927,29 @@ export interface WorkerToHostNotifications {
channel: string;
companyId: string;
};
+
+ /**
+ * Deliver one incremental output chunk of the active `environmentExecute`
+ * call to the host runner log sink.
+ *
+ * The worker emits this notification for each new `stdout` or `stderr` chunk
+ * while one execute call runs. The host reads the active invocation id from
+ * the envelope field `paperclipInvocationId`, which the worker RPC host stamps
+ * from the active invocation context. The host correlates the chunk to the
+ * host-owned execute route for that id and delivers it to that route's
+ * `onLog` callback.
+ *
+ * Security: the notification carries no company id on purpose. The
+ * invocation-to-company binding on the host execute route is authoritative.
+ * The host never reads a company id from this payload to select the route or
+ * to grant access. The `chunk` is a text string, because JSON-RPC cannot
+ * carry raw bytes; the host drops a chunk that is not a bounded non-empty
+ * string or whose stream name is not exactly `stdout` or `stderr`.
+ */
+ "execute.log": {
+ stream: "stdout" | "stderr";
+ chunk: string;
+ };
}
/** Union of all worker→host notification method names. */
diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts
index 225fbecc430..af12e9a8c8c 100644
--- a/packages/plugins/sdk/src/testing.ts
+++ b/packages/plugins/sdk/src/testing.ts
@@ -2473,6 +2473,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
},
};
})(),
+ execution: {
+ log(_stream: "stdout" | "stderr", _chunk: string) {
+ // No-op in test harness — the host runner log sink is not wired here.
+ },
+ },
tools: {
register(name, _decl, fn) {
requireCapability(manifest, capabilitySet, "agent.tools.register");
diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts
index ed1daedbf4d..adfad498d9e 100644
--- a/packages/plugins/sdk/src/types.ts
+++ b/packages/plugins/sdk/src/types.ts
@@ -1984,6 +1984,30 @@ export interface PluginStreamsClient {
close(channel: string): void;
}
+/**
+ * `ctx.execution` — deliver incremental command output from an environment
+ * driver's active `execute` call to the host runner log sink.
+ *
+ * A sandbox provider that streams a long-lived command's output calls
+ * `ctx.execution.log(stream, chunk)` for each new chunk while the execute call
+ * runs. The host correlates the chunk to the active execute invocation by the
+ * host-issued invocation id on the message envelope, and delivers it to that
+ * call's log callback before the final result. The default is a no-op that
+ * never throws, so a provider that does not stream keeps its current behavior.
+ *
+ * The `chunk` is a text string, not raw bytes. The host drops a chunk with an
+ * unknown stream name or a chunk that is empty or too large.
+ */
+export interface PluginExecutionClient {
+ /**
+ * Deliver one incremental output chunk of the active execute call.
+ *
+ * @param stream - Either `"stdout"` or `"stderr"`.
+ * @param chunk - The new output text for that stream.
+ */
+ log(stream: "stdout" | "stderr", chunk: string): void;
+}
+
// ---------------------------------------------------------------------------
// Full plugin context
// ---------------------------------------------------------------------------
@@ -2094,6 +2118,11 @@ export interface PluginContext {
/** Push real-time events from the worker to the plugin UI via SSE. */
streams: PluginStreamsClient;
+ /** Deliver incremental command output from the active execute call to the
+ * host runner log sink. The default is a no-op for a provider that does not
+ * stream. */
+ execution: PluginExecutionClient;
+
/** Register agent tool handlers. Requires `agent.tools.register`. */
tools: PluginToolsClient;
diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts
index 8b56a48972b..ccad211040f 100644
--- a/packages/plugins/sdk/src/worker-rpc-host.ts
+++ b/packages/plugins/sdk/src/worker-rpc-host.ts
@@ -1353,6 +1353,19 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
};
})(),
+ execution: {
+ log(stream: "stdout" | "stderr", chunk: string): void {
+ // Emit one incremental output chunk of the active execute call.
+ // `notifyHost` stamps the active invocation id from the invocation
+ // context, so the host correlates the chunk to the host-owned execute
+ // route for that call. The notification carries no company id; the
+ // host binds the company from its own execute route. A chunk sent with
+ // no active invocation carries no id and the host drops it.
+ if (typeof chunk !== "string" || chunk.length === 0) return;
+ notifyHost("execute.log", { stream, chunk });
+ },
+ },
+
tools: {
register(
name: string,
diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts
index e88833671b9..c178a2fe2ec 100644
--- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts
+++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts
@@ -630,3 +630,128 @@ describe("worker provider tracer", () => {
expect(spanRecords).toHaveLength(0);
});
});
+
+describe("worker execute.log emitter", () => {
+ // Run one data handler that calls `ctx.execution.log`, and capture the
+ // `execute.log` notifications the worker sends to the host.
+ async function runExecuteLogProbe(
+ invocation: PluginInvocationContext | undefined,
+ entries: Array<{ stream: "stdout" | "stderr"; chunk: string }>,
+ ) {
+ const hostToWorker = new PassThrough();
+ const workerToHost = new PassThrough();
+ const hostReadline = createInterface({ input: workerToHost });
+ const pending = new Map void>();
+ const logRecords: Array<{ params: unknown; invocationId?: string }> = [];
+ let nextRequestId = 1;
+
+ const plugin = definePlugin({
+ async setup(ctx) {
+ ctx.data.register("emit-logs", async () => {
+ for (const entry of entries) {
+ ctx.execution.log(entry.stream, entry.chunk);
+ }
+ return { ok: true };
+ });
+ },
+ });
+
+ const worker = startWorkerRpcHost({ plugin, stdin: hostToWorker, stdout: workerToHost });
+
+ function callWorker(method: string, params: unknown, inv?: PluginInvocationContext) {
+ const id = `host-${nextRequestId++}`;
+ const request = {
+ ...createRequest(method, params, id),
+ ...(inv ? { paperclipInvocation: inv } : {}),
+ };
+ const result = new Promise((resolve, reject) => {
+ pending.set(id, (response) => {
+ if ("error" in response && response.error) {
+ reject(new Error(response.error.message));
+ return;
+ }
+ resolve((response as { result?: unknown }).result);
+ });
+ });
+ hostToWorker.write(serializeMessage(request));
+ return result;
+ }
+
+ hostReadline.on("line", (line) => {
+ const message = parseMessage(line);
+ if (isJsonRpcResponse(message)) {
+ pending.get(String(message.id))?.(message);
+ pending.delete(String(message.id));
+ return;
+ }
+ // `execute.log` is a fire-and-forget notification (no id), so it is not a
+ // JSON-RPC request. Match on the method name directly.
+ if ((message as { method?: string }).method === "execute.log") {
+ logRecords.push({
+ params: (message as { params?: unknown }).params,
+ invocationId: (message as { paperclipInvocationId?: string }).paperclipInvocationId,
+ });
+ }
+ });
+
+ try {
+ await callWorker("initialize", {
+ manifest: {
+ id: "paperclip.execute-log-test",
+ apiVersion: 1,
+ version: "1.0.0",
+ displayName: "Execute log test",
+ description: "Execute log test",
+ author: "Paperclip",
+ categories: ["automation"],
+ capabilities: ["environment.drivers.register"],
+ entrypoints: { worker: "dist/worker.js" },
+ },
+ config: {},
+ instanceInfo: { instanceId: "test", hostVersion: "0.0.0" },
+ apiVersion: 1,
+ });
+ await callWorker("getData", { key: "emit-logs", companyId: "company-a", params: {} }, invocation);
+ // Let the fire-and-forget execute.log notifications flush.
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ return logRecords;
+ } finally {
+ worker.stop();
+ hostReadline.close();
+ hostToWorker.destroy();
+ workerToHost.destroy();
+ }
+ }
+
+ it("stamps the active invocation id on each execute.log notification", async () => {
+ const records = await runExecuteLogProbe(
+ { id: "invocation-a", scope: { companyId: "company-a" } },
+ [
+ { stream: "stdout", chunk: "one" },
+ { stream: "stderr", chunk: "two" },
+ ],
+ );
+ expect(records).toHaveLength(2);
+ expect(records[0]).toEqual({
+ params: { stream: "stdout", chunk: "one" },
+ invocationId: "invocation-a",
+ });
+ expect(records[1]).toEqual({
+ params: { stream: "stderr", chunk: "two" },
+ invocationId: "invocation-a",
+ });
+ });
+
+ it("drops an empty chunk before it reaches the host", async () => {
+ const records = await runExecuteLogProbe(
+ { id: "invocation-a", scope: { companyId: "company-a" } },
+ [
+ { stream: "stdout", chunk: "" },
+ { stream: "stdout", chunk: "kept" },
+ ],
+ );
+ expect(records).toEqual([
+ { params: { stream: "stdout", chunk: "kept" }, invocationId: "invocation-a" },
+ ]);
+ });
+});
diff --git a/packages/shared/src/types/environment.ts b/packages/shared/src/types/environment.ts
index 2a2867ffc1c..aa08f82ab75 100644
--- a/packages/shared/src/types/environment.ts
+++ b/packages/shared/src/types/environment.ts
@@ -30,6 +30,11 @@ export interface FakeSandboxEnvironmentConfig {
reuseLease: boolean;
/** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */
streamRunLogs?: boolean;
+ /**
+ * Stream the interactive ACP agent output through the persistent session log
+ * stream instead of the host output-file poll. Default OFF.
+ */
+ streamAgentSessionOutput?: boolean;
/**
* Archive the sandbox on lease release instead of deleting it, so operators
* can inspect it from the provider dashboard. Injected by test/probe paths;
@@ -44,6 +49,11 @@ export interface PluginSandboxEnvironmentConfig {
timeoutMs?: number;
/** Stream agent CLI stdout/stderr during sandbox runs (bridge log-tail loop). */
streamRunLogs?: boolean;
+ /**
+ * Stream the interactive ACP agent output through the persistent session log
+ * stream instead of the host output-file poll. Default OFF.
+ */
+ streamAgentSessionOutput?: boolean;
/**
* Archive the sandbox on lease release instead of deleting it, so operators
* can inspect it from the provider dashboard. Injected by test/probe paths;
diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts
index 558621fb9ac..662cf4c0719 100644
--- a/server/src/__tests__/environment-execution-target.test.ts
+++ b/server/src/__tests__/environment-execution-target.test.ts
@@ -453,6 +453,70 @@ describe("resolveEnvironmentExecutionTarget", () => {
expect(environmentRuntime.execute).toHaveBeenCalledTimes(2);
});
+ it("forwards the session flags to the environment runtime execute", async () => {
+ mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({
+ driver: "sandbox",
+ config: {
+ provider: "fake-plugin",
+ reuseLease: false,
+ timeoutMs: 30_000,
+ },
+ });
+
+ const environmentRuntime = {
+ execute: vi.fn().mockResolvedValue({
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ stdout: "ok",
+ stderr: "",
+ metadata: { durationMs: 600, getDurationMs: 15 },
+ }),
+ supportsSync: vi.fn().mockReturnValue(false),
+ };
+
+ const target = await resolveEnvironmentExecutionTarget({
+ db: {} as never,
+ companyId: "company-1",
+ adapterType: "codex_local",
+ environment: { id: "env-1", driver: "sandbox", config: { provider: "fake-plugin" } },
+ leaseId: "lease-1",
+ leaseMetadata: { remoteCwd: "/workspace" },
+ lease: { id: "lease-1" } as never,
+ environmentRuntime: environmentRuntime as never,
+ });
+
+ const runner = (target as { runner?: {
+ execute(input: {
+ command: string;
+ args?: string[];
+ useSession?: boolean;
+ bypassSession?: boolean;
+ }): Promise;
+ } }).runner!;
+
+ // The agent command opts onto the persistent session with `useSession`,
+ // which the seam maps to `forceSession`. It never bypasses the session.
+ await runner.execute({ command: "node", args: ["script.js"], useSession: true });
+ // A bridge control-plane exec opts off the persistent session with
+ // `bypassSession`, which the seam forwards unchanged.
+ await runner.execute({ command: "sh", args: ["-c", "cat"], bypassSession: true });
+
+ const first = environmentRuntime.execute.mock.calls[0]![0] as {
+ forceSession?: boolean;
+ bypassSession?: boolean;
+ };
+ expect(first.forceSession).toBe(true);
+ expect(first.bypassSession).toBeUndefined();
+
+ const second = environmentRuntime.execute.mock.calls[1]![0] as {
+ forceSession?: boolean;
+ bypassSession?: boolean;
+ };
+ expect(second.forceSession).toBeUndefined();
+ expect(second.bypassSession).toBe(true);
+ });
+
// A recording tracer that captures each provider-exec span's name, attribute
// map, and end. It satisfies the structural tracer the seam calls.
function createRecordingExecTracer() {
@@ -549,6 +613,122 @@ describe("resolveEnvironmentExecutionTarget", () => {
} }).runner!;
}
+ // Run the sandbox runner's execute with an incremental log sink and collect
+ // the ordered deliveries. The runner's execute accepts an `onLog`, so this
+ // casts past the narrowed helper return type.
+ async function runExecuteCollectingLogs(
+ runner: { execute(input: unknown): Promise },
+ ): Promise> {
+ const delivered: Array<[string, string]> = [];
+ await runner.execute({
+ command: "echo",
+ onLog: async (stream: "stdout" | "stderr", chunk: string) => {
+ delivered.push([stream, chunk]);
+ },
+ });
+ return delivered;
+ }
+
+ it("delivers only the un-streamed suffix after a provider streams a prefix then polls the complete result", async () => {
+ // The provider streams a prefix through the incremental sink, then its
+ // stream fails and it polls the complete output as the final result. The
+ // reconciler must deliver the remaining tail once, so no output byte is lost
+ // or repeated.
+ const { tracer } = createRecordingExecTracer();
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ execute: async (input: unknown) => {
+ const typed = input as { onLog?: (s: "stdout" | "stderr", c: string) => Promise };
+ await typed.onLog?.("stdout", "hello ");
+ await typed.onLog?.("stderr", "warn:");
+ return { exitCode: 0, signal: null, timedOut: false, stdout: "hello world", stderr: "warn:done" };
+ },
+ });
+ const delivered = await runExecuteCollectingLogs(
+ runner as { execute(input: unknown): Promise },
+ );
+ expect(delivered).toEqual([
+ ["stdout", "hello "],
+ ["stderr", "warn:"],
+ ["stdout", "world"],
+ ["stderr", "done"],
+ ]);
+ });
+
+ it("does not repeat output when the provider already streamed the complete result", async () => {
+ // The provider streams the whole output through the incremental sink and
+ // returns the same complete result. The suffix is empty, so the reconciler
+ // never re-delivers the streamed bytes.
+ const { tracer } = createRecordingExecTracer();
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ execute: async (input: unknown) => {
+ const typed = input as { onLog?: (s: "stdout" | "stderr", c: string) => Promise };
+ await typed.onLog?.("stdout", "full");
+ return { exitCode: 0, signal: null, timedOut: false, stdout: "full", stderr: "" };
+ },
+ });
+ const delivered = await runExecuteCollectingLogs(
+ runner as { execute(input: unknown): Promise },
+ );
+ expect(delivered).toEqual([["stdout", "full"]]);
+ });
+
+ it("delivers the full captured output when the provider streams nothing incrementally", async () => {
+ // The provider streams no incremental chunk, so the whole final result is
+ // the suffix and reaches the sink once.
+ const { tracer } = createRecordingExecTracer();
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ execute: async () => ({
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ stdout: "batch-out",
+ stderr: "batch-err",
+ }),
+ });
+ const delivered = await runExecuteCollectingLogs(
+ runner as { execute(input: unknown): Promise },
+ );
+ expect(delivered).toEqual([
+ ["stdout", "batch-out"],
+ ["stderr", "batch-err"],
+ ]);
+ });
+
+ it("delivers the whole final output when the poll fallback buffer does not continue the streamed prefix", async () => {
+ // The provider streams a prefix, then its stream fails and it polls a
+ // buffer that does NOT start with that prefix. A length slice would drop
+ // the leading bytes of the poll buffer and corrupt the durable log, so the
+ // reconciler delivers the whole final output instead. The streamed prefix
+ // repeats, but no output byte is lost or truncated.
+ const { tracer } = createRecordingExecTracer();
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ execute: async (input: unknown) => {
+ const typed = input as { onLog?: (s: "stdout" | "stderr", c: string) => Promise };
+ await typed.onLog?.("stdout", "hello ");
+ await typed.onLog?.("stderr", "warn:");
+ // The poll buffer starts with different leading text on both streams.
+ return { exitCode: 0, signal: null, timedOut: false, stdout: "RESYNCED output", stderr: "RESET err" };
+ },
+ });
+ const delivered = await runExecuteCollectingLogs(
+ runner as { execute(input: unknown): Promise },
+ );
+ expect(delivered).toEqual([
+ ["stdout", "hello "],
+ ["stderr", "warn:"],
+ ["stdout", "RESYNCED output"],
+ ["stderr", "RESET err"],
+ ]);
+ });
+
it("sets the provider duration attributes from finite Daytona-shaped metadata", async () => {
const { tracer, spans } = createRecordingExecTracer();
const runner = await runnerFor({
@@ -947,6 +1127,138 @@ describe("resolveEnvironmentExecutionTarget", () => {
expect(onLog).toHaveBeenCalledTimes(1);
});
+ it("delivers incremental logs before the final result and does not duplicate them", async () => {
+ const { tracer } = createRecordingExecTracer();
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ execute: vi.fn(async (input: { onLog?: (s: string, c: string) => Promise }) => {
+ // The provider streams the output while the command runs.
+ await input.onLog?.("stdout", "chunk-1");
+ await input.onLog?.("stderr", "chunk-2");
+ await input.onLog?.("stdout", "chunk-3");
+ return {
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ stdout: "chunk-1chunk-3",
+ stderr: "chunk-2",
+ };
+ }),
+ });
+
+ const onLog = vi.fn();
+ const result = await (runner as {
+ execute(input: unknown): Promise<{ stdout: string; stderr: string; exitCode: number }>;
+ }).execute({ command: "echo", onLog });
+
+ // The runner receives the incremental chunks in order, and NOT a repeated
+ // delivery of the final stdout/stderr.
+ expect(onLog.mock.calls).toEqual([
+ ["stdout", "chunk-1"],
+ ["stderr", "chunk-2"],
+ ["stdout", "chunk-3"],
+ ]);
+ // The final result stays available to the caller for parsing and fallback.
+ expect(result).toMatchObject({ exitCode: 0, stdout: "chunk-1chunk-3", stderr: "chunk-2" });
+ });
+
+ it("still delivers the final result to the runner when the provider does not stream", async () => {
+ const { tracer } = createRecordingExecTracer();
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ // A provider that never calls onLog returns only the final result.
+ execute: vi.fn(async () => ({
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ stdout: "final-out",
+ stderr: "final-err",
+ })),
+ });
+
+ const onLog = vi.fn();
+ const result = await (runner as {
+ execute(input: unknown): Promise<{ stdout: string; stderr: string }>;
+ }).execute({ command: "echo", onLog });
+
+ expect(onLog.mock.calls).toEqual([
+ ["stdout", "final-out"],
+ ["stderr", "final-err"],
+ ]);
+ expect(result).toMatchObject({ stdout: "final-out", stderr: "final-err" });
+ });
+
+ it("creates exactly one sandbox.exec span for one streamed provider call", async () => {
+ const { tracer, spans } = createRecordingExecTracer();
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ execute: vi.fn(async (input: { onLog?: (s: string, c: string) => Promise }) => {
+ await input.onLog?.("stdout", "chunk-a");
+ await input.onLog?.("stdout", "chunk-b");
+ await input.onLog?.("stderr", "chunk-c");
+ return {
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ stdout: "chunk-achunk-b",
+ stderr: "chunk-c",
+ };
+ }),
+ });
+
+ const onLog = vi.fn();
+ await (runner as { execute(input: unknown): Promise }).execute({
+ command: "echo",
+ onLog,
+ });
+
+ // One long-lived provider call opens one span; each stream chunk opens none.
+ expect(spans).toHaveLength(1);
+ expect(spans[0]!.name).toBe("sandbox.exec");
+ expect(spans[0]!.ended).toBe(true);
+ });
+
+ it("keeps streamed log text and secret values out of span attributes", async () => {
+ const { tracer, spans } = createRecordingExecTracer();
+ const secret = "sk-super-secret-value";
+ const runner = await runnerWithExecute({
+ provider: "daytona",
+ tracer,
+ execute: vi.fn(async (input: { onLog?: (s: string, c: string) => Promise }) => {
+ await input.onLog?.("stdout", `token=${secret}\n`);
+ return {
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ stdout: `token=${secret}\n`,
+ stderr: "",
+ };
+ }),
+ });
+
+ const onLog = vi.fn();
+ // The secret rides the env and the streamed chunk, never the command label.
+ await (runner as { execute(input: unknown): Promise }).execute({
+ command: "run-agent",
+ env: { API_KEY: secret },
+ onLog,
+ });
+
+ expect(spans).toHaveLength(1);
+ const span = spans[0]!;
+ // Attributes carry only the closed allowlist — never log text or secrets.
+ for (const key of Object.keys(span.attributes)) {
+ expect(ALLOWED_EXEC_SPAN_ATTRIBUTE_KEYS.has(key), `non-allowlisted key "${key}"`).toBe(true);
+ }
+ const serialized = JSON.stringify(span.attributes);
+ expect(serialized).not.toContain(secret);
+ expect(serialized).not.toContain("token=");
+ expect(serialized).not.toContain("chunk");
+ });
+
// Fire one run-time exec from a bridge continuation that runs after the step
// span ended. Each bridge step (`bridge.paperclip`, `bridge.process-session`)
// starts long-lived work with `criticalPath: false`. The bridge boundary wraps
diff --git a/server/src/__tests__/environment-routes.test.ts b/server/src/__tests__/environment-routes.test.ts
index 06c04d2d946..b838aed1a78 100644
--- a/server/src/__tests__/environment-routes.test.ts
+++ b/server/src/__tests__/environment-routes.test.ts
@@ -1587,6 +1587,64 @@ describe("environment routes", () => {
expect(mockSecretService.create).not.toHaveBeenCalled();
});
+ it("keeps host-owned stream flags when the provider plugin drops them from its normalized config", async () => {
+ // The host owns `streamRunLogs` and `streamAgentSessionOutput`. It reads
+ // them to select the run-log stream and the ACP session output stream. A
+ // provider plugin normalizes only its own driver fields, so it drops these
+ // host flags from its normalized config. The host must re-apply them, or the
+ // saved environment loses the operator opt-in and the streams never start.
+ const environment = {
+ ...createEnvironment(),
+ id: "env-sandbox-fake-plugin",
+ name: "Streamed Sandbox",
+ driver: "sandbox" as const,
+ config: { provider: "fake-plugin", image: "fake:test" },
+ };
+ mockEnvironmentService.create.mockResolvedValue(environment);
+ mockValidatePluginSandboxProviderConfig.mockImplementation(async ({ provider, config }) => {
+ // Drop the host flags to reproduce a plugin that allowlists driver fields.
+ const { streamRunLogs, streamAgentSessionOutput, ...driverConfig } =
+ config as Record;
+ void streamRunLogs;
+ void streamAgentSessionOutput;
+ return {
+ normalizedConfig: driverConfig,
+ pluginId: `plugin-${provider}`,
+ pluginKey: `plugin.${provider}`,
+ driver: {
+ driverKey: provider,
+ kind: "sandbox_provider",
+ displayName: provider,
+ configSchema: { type: "object" },
+ },
+ };
+ });
+ const pluginWorkerManager = {};
+ const app = createApp({
+ type: "board",
+ userId: "user-1",
+ source: "local_implicit",
+ }, { pluginWorkerManager });
+
+ const res = await request(app)
+ .post("/api/companies/company-1/environments")
+ .send({
+ name: "Streamed Sandbox",
+ driver: "sandbox",
+ config: {
+ provider: "fake-plugin",
+ image: "fake:test",
+ streamRunLogs: false,
+ streamAgentSessionOutput: true,
+ },
+ });
+
+ expect(res.status).toBe(201);
+ const persisted = mockEnvironmentService.create.mock.calls[0][0].config as Record;
+ expect(persisted.streamAgentSessionOutput).toBe(true);
+ expect(persisted.streamRunLogs).toBe(false);
+ });
+
it("creates a schema-driven sandbox environment with secret-ref fields persisted as secrets", async () => {
const environment = {
...createEnvironment(),
diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts
index 7397b997323..01210c1bdad 100644
--- a/server/src/__tests__/environment-runtime.test.ts
+++ b/server/src/__tests__/environment-runtime.test.ts
@@ -702,7 +702,9 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(executed.stdout).toBe("ok\n");
expect(released).toHaveLength(1);
expect(released[0]?.lease.status).toBe("released");
- expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything(), 31000);
+ // The execute call carries the optional log sink as the fifth argument; it
+ // is undefined when the caller passes no sink.
+ expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.anything(), 31000, undefined);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.anything(), 31234);
});
@@ -1385,11 +1387,13 @@ describeEmbeddedPostgres("environmentRuntimeService", () => {
expect(executed.stdout).toBe("ok\n");
expect(released).toHaveLength(1);
expect(released[0]?.lease.status).toBe("released");
+ // The execute call carries the optional log sink as the fifth argument; it
+ // is undefined when the caller passes no sink.
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentExecute", expect.objectContaining({
config: expect.objectContaining({
apiKey: "resolved-provider-key",
}),
- }), 31234);
+ }), 31234, undefined);
expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentReleaseLease", expect.objectContaining({
config: expect.objectContaining({
apiKey: "resolved-provider-key",
diff --git a/server/src/__tests__/fixtures/plugin-worker-execute-log.cjs b/server/src/__tests__/fixtures/plugin-worker-execute-log.cjs
new file mode 100644
index 00000000000..7d7feacb66b
--- /dev/null
+++ b/server/src/__tests__/fixtures/plugin-worker-execute-log.cjs
@@ -0,0 +1,117 @@
+// Test worker fixture for the `execute.log` worker→host notification route.
+//
+// On `environmentExecute` the fixture emits the `execute.log` notifications
+// listed in `params.logs`, then returns the final result. Each log entry sets
+// the envelope invocation id by its `tag`:
+// - "echo" → the real host-issued id (the normal streaming path)
+// - "unknown" → a forged id with no active route (must be dropped)
+// - "none" → no id at all (must be dropped)
+// - "forge-previous" → the id of the PREVIOUS execute call the same worker
+// process handled. One worker process sees every active
+// invocation id, so this reproduces a worker that runs
+// company A and forges company B's active id.
+// The default tag is "echo". The entry `stream` and `chunk` pass through
+// verbatim, so a test can send an invalid stream name or an empty chunk to
+// prove the host drops invalid input.
+//
+// When `params.oversizedLogChunkChars > 0` the fixture emits one VALID
+// `execute.log` note (own id) whose chunk holds that many characters BEFORE the
+// normal logs, so a test can prove the host drops an over-length line before it
+// parses the JSON.
+const readline = require("node:readline");
+
+function send(message) {
+ process.stdout.write(`${JSON.stringify(message)}\n`);
+}
+
+// The id of the previous execute call this worker process handled. A single
+// worker process serves every company, so it can name another company's active
+// invocation. The fixture uses this to forge a peer id.
+let previousInvocationId = null;
+
+const rl = readline.createInterface({
+ input: process.stdin,
+ crlfDelay: Infinity,
+});
+
+rl.on("line", (line) => {
+ if (!line.trim()) return;
+ const message = JSON.parse(line);
+ const method = message && typeof message.method === "string" ? message.method : null;
+
+ if (method === "initialize") {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: { ok: true, supportedMethods: ["environmentExecute"] },
+ });
+ return;
+ }
+
+ if (method === "environmentExecute") {
+ const invocationId = message.paperclipInvocation && message.paperclipInvocation.id;
+ const forgeableId = previousInvocationId;
+ previousInvocationId = invocationId;
+ const params = message.params ?? {};
+ const oversizedChars = Number(params.oversizedLogChunkChars ?? 0);
+ if (oversizedChars > 0) {
+ send({
+ jsonrpc: "2.0",
+ method: "execute.log",
+ paperclipInvocationId: invocationId,
+ params: { stream: "stdout", chunk: "d".repeat(oversizedChars) },
+ });
+ }
+ const logs = Array.isArray(params.logs) ? params.logs : [];
+ for (const entry of logs) {
+ const note = {
+ jsonrpc: "2.0",
+ method: "execute.log",
+ params: { stream: entry.stream, chunk: entry.chunk },
+ };
+ const tag = entry.tag ?? "echo";
+ if (tag === "echo") {
+ note.paperclipInvocationId = invocationId;
+ } else if (tag === "unknown") {
+ note.paperclipInvocationId = "unknown-invocation";
+ } else if (tag === "forge-previous") {
+ note.paperclipInvocationId = forgeableId;
+ }
+ // tag === "none" → omit the invocation id entirely.
+ send(note);
+ }
+
+ const finish = () => {
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ result: {
+ exitCode: 0,
+ signal: null,
+ timedOut: false,
+ stdout: params.finalStdout ?? "",
+ stderr: params.finalStderr ?? "",
+ },
+ });
+ };
+ const delayMs = Number(params.delayMs ?? 0);
+ if (delayMs > 0) {
+ setTimeout(finish, delayMs);
+ } else {
+ finish();
+ }
+ return;
+ }
+
+ if (method === "shutdown") {
+ send({ jsonrpc: "2.0", id: message.id, result: {} });
+ setImmediate(() => process.exit(0));
+ return;
+ }
+
+ send({
+ jsonrpc: "2.0",
+ id: message.id,
+ error: { code: -32601, message: `Unhandled method: ${method}` },
+ });
+});
diff --git a/server/src/__tests__/plugin-worker-manager.test.ts b/server/src/__tests__/plugin-worker-manager.test.ts
index 451e7d1c60a..7d837a47ddf 100644
--- a/server/src/__tests__/plugin-worker-manager.test.ts
+++ b/server/src/__tests__/plugin-worker-manager.test.ts
@@ -23,6 +23,7 @@ const INVOCATION_SCOPE_WORKER_ENTRYPOINT = path.join(
"plugin-worker-invocation-scope.cjs",
);
const TERMINATED_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-terminated.cjs");
+const EXECUTE_LOG_WORKER_ENTRYPOINT = path.join(FIXTURES_DIR, "plugin-worker-execute-log.cjs");
const TEST_MANIFEST: PaperclipPluginManifestV1 = {
id: "test.plugin",
@@ -779,3 +780,277 @@ describe("plugin proactive events.subscribe: options-seeded scope + filter parit
}
});
});
+
+// ---------------------------------------------------------------------------
+// execute.log worker→host notification route
+// ---------------------------------------------------------------------------
+
+function makeExecuteLogHandle(extra?: Record) {
+ return createPluginWorkerHandle("test.plugin", {
+ entrypointPath: EXECUTE_LOG_WORKER_ENTRYPOINT,
+ manifest: TEST_MANIFEST,
+ config: {},
+ instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
+ apiVersion: 1,
+ hostHandlers: {},
+ ...extra,
+ });
+}
+
+function executeParams(
+ overrides: Record,
+): HostToWorkerMethods["environmentExecute"][0] {
+ return {
+ driverKey: "daytona",
+ companyId: "company-1",
+ environmentId: "env-1",
+ config: {},
+ lease: { providerLeaseId: "lease-1" },
+ command: "echo",
+ ...overrides,
+ } as unknown as HostToWorkerMethods["environmentExecute"][0];
+}
+
+describe("plugin worker manager execute.log route", () => {
+ it("delivers ordered execute.log chunks to the execute log sink", async () => {
+ const handle = makeExecuteLogHandle();
+ const sink = vi.fn();
+ try {
+ await handle.start();
+ const result = await handle.call(
+ "environmentExecute",
+ executeParams({
+ logs: [
+ { stream: "stdout", chunk: "one" },
+ { stream: "stderr", chunk: "two" },
+ { stream: "stdout", chunk: "three" },
+ ],
+ finalStdout: "onethree",
+ finalStderr: "two",
+ }),
+ undefined,
+ sink,
+ );
+ expect(result).toMatchObject({ exitCode: 0 });
+ expect(sink.mock.calls).toEqual([
+ ["stdout", "one"],
+ ["stderr", "two"],
+ ["stdout", "three"],
+ ]);
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("drops an execute.log chunk with a forged or missing invocation id", async () => {
+ const handle = makeExecuteLogHandle();
+ const sink = vi.fn();
+ try {
+ await handle.start();
+ await handle.call(
+ "environmentExecute",
+ executeParams({
+ logs: [
+ { stream: "stdout", chunk: "valid", tag: "echo" },
+ { stream: "stdout", chunk: "forged", tag: "unknown" },
+ { stream: "stdout", chunk: "orphan", tag: "none" },
+ ],
+ }),
+ undefined,
+ sink,
+ );
+ // Only the chunk that carries this call's own host-issued id is delivered.
+ expect(sink.mock.calls).toEqual([["stdout", "valid"]]);
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("drops an execute.log chunk with an invalid stream name or an empty chunk", async () => {
+ const handle = makeExecuteLogHandle();
+ const sink = vi.fn();
+ try {
+ await handle.start();
+ await handle.call(
+ "environmentExecute",
+ executeParams({
+ logs: [
+ { stream: "stdout", chunk: "keep" },
+ { stream: "bogus", chunk: "dropped-stream" },
+ { stream: "stdout", chunk: "" },
+ ],
+ }),
+ undefined,
+ sink,
+ );
+ expect(sink.mock.calls).toEqual([["stdout", "keep"]]);
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("routes two concurrent same-company execute calls to their own sink only", async () => {
+ const handle = makeExecuteLogHandle();
+ const sinkA = vi.fn();
+ const sinkB = vi.fn();
+ try {
+ await handle.start();
+ const callA = handle.call(
+ "environmentExecute",
+ executeParams({
+ companyId: "company-1",
+ logs: [{ stream: "stdout", chunk: "a1" }],
+ delayMs: 40,
+ }),
+ undefined,
+ sinkA,
+ );
+ const callB = handle.call(
+ "environmentExecute",
+ executeParams({
+ companyId: "company-1",
+ logs: [{ stream: "stdout", chunk: "b1" }],
+ delayMs: 40,
+ }),
+ undefined,
+ sinkB,
+ );
+ await Promise.all([callA, callB]);
+ // Both calls belong to one company, so the shared pipe stays
+ // single-company and each chunk reaches only its own call's sink.
+ expect(sinkA.mock.calls).toEqual([["stdout", "a1"]]);
+ expect(sinkB.mock.calls).toEqual([["stdout", "b1"]]);
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("fails closed and never delivers execute.log across companies, even with a forged peer id", async () => {
+ // A single worker process serves every company, so it knows both companies'
+ // active invocation ids. While company B's execute stays active, company A's
+ // execute forges B's known, valid id and aims a chunk at B's route. The host
+ // must not deliver it to B. Before the exact-company-scope validation, the
+ // route lookup by the worker-supplied id delivered the forged chunk to B.
+ const handle = makeExecuteLogHandle();
+ const sinkA = vi.fn();
+ const sinkB = vi.fn();
+ try {
+ await handle.start();
+ // Company B opens first and stays active (delayed finish), so its route is
+ // registered and known to the worker when company A runs.
+ const callB = handle.call(
+ "environmentExecute",
+ executeParams({ companyId: "company-b", logs: [], delayMs: 200 }),
+ undefined,
+ sinkB,
+ );
+ // Let the worker process B's execute, so it records B's id as the peer id.
+ await new Promise((resolve) => setTimeout(resolve, 40));
+ const callA = handle.call(
+ "environmentExecute",
+ executeParams({
+ companyId: "company-a",
+ logs: [{ stream: "stdout", chunk: "forged-into-b", tag: "forge-previous" }],
+ }),
+ undefined,
+ sinkA,
+ );
+ await Promise.all([callA, callB]);
+ expect(sinkB).not.toHaveBeenCalled();
+ expect(sinkA).not.toHaveBeenCalled();
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("drops execute.log chunks once one execute call exceeds its output budget", async () => {
+ // Bound the total streamed output for one execute call. Past the ceiling the
+ // host drops further chunks, so one runaway or hostile execution cannot flood
+ // the host without limit.
+ const handle = makeExecuteLogHandle({
+ executeLogLimits: { maxTotalCharsPerExecute: 10 },
+ });
+ const sink = vi.fn();
+ try {
+ await handle.start();
+ await handle.call(
+ "environmentExecute",
+ executeParams({
+ logs: [
+ { stream: "stdout", chunk: "aaaaa" }, // total 5 → delivered
+ { stream: "stdout", chunk: "bbbbb" }, // total 10 → delivered
+ { stream: "stdout", chunk: "c" }, // total 11 > 10 → dropped
+ ],
+ }),
+ undefined,
+ sink,
+ );
+ expect(sink.mock.calls).toEqual([
+ ["stdout", "aaaaa"],
+ ["stdout", "bbbbb"],
+ ]);
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("drops an over-length worker line before parsing it and keeps serving the call", async () => {
+ // Enforce the framing bound before the JSON parse. The oversized note is a
+ // valid execute.log line for this call's own id, so without the pre-parse
+ // guard the host would parse and deliver it. The normal note stays under the
+ // limit and reaches the sink, and the call still completes.
+ const handle = makeExecuteLogHandle({
+ executeLogLimits: { maxIncomingMessageChars: 400 },
+ });
+ const sink = vi.fn();
+ try {
+ await handle.start();
+ const result = await handle.call(
+ "environmentExecute",
+ executeParams({
+ oversizedLogChunkChars: 1_000,
+ logs: [{ stream: "stdout", chunk: "kept" }],
+ finalStdout: "kept",
+ }),
+ undefined,
+ sink,
+ );
+ expect(result).toMatchObject({ exitCode: 0 });
+ expect(sink.mock.calls).toEqual([["stdout", "kept"]]);
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("completes an execute call that sends no execute.log notification", async () => {
+ const handle = makeExecuteLogHandle();
+ const sink = vi.fn();
+ try {
+ await handle.start();
+ const result = await handle.call(
+ "environmentExecute",
+ executeParams({ logs: [], finalStdout: "done" }),
+ undefined,
+ sink,
+ );
+ expect(result).toMatchObject({ exitCode: 0, stdout: "done" });
+ expect(sink).not.toHaveBeenCalled();
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+
+ it("does not throw when execute.log arrives but no sink is registered", async () => {
+ const handle = makeExecuteLogHandle();
+ try {
+ await handle.start();
+ const result = await handle.call(
+ "environmentExecute",
+ executeParams({ logs: [{ stream: "stdout", chunk: "no-sink" }] }),
+ );
+ expect(result).toMatchObject({ exitCode: 0 });
+ } finally {
+ await handle.stop().catch(() => undefined);
+ }
+ });
+});
diff --git a/server/src/services/environment-config.ts b/server/src/services/environment-config.ts
index efd33c818e4..433839de826 100644
--- a/server/src/services/environment-config.ts
+++ b/server/src/services/environment-config.ts
@@ -77,6 +77,7 @@ const fakeSandboxEnvironmentConfigSchema = z.object({
.default("ubuntu:24.04"),
reuseLease: z.boolean().optional().default(false),
streamRunLogs: z.boolean().optional(),
+ streamAgentSessionOutput: z.boolean().optional(),
archiveOnRelease: z.boolean().optional(),
}).strict();
@@ -93,6 +94,7 @@ const pluginSandboxEnvironmentConfigSchema = z.object({
timeoutMs: z.coerce.number().int().min(1).max(86_400_000).optional(),
reuseLease: z.boolean().optional().default(false),
streamRunLogs: z.boolean().optional(),
+ streamAgentSessionOutput: z.boolean().optional(),
archiveOnRelease: z.boolean().optional(),
}).catchall(z.unknown());
@@ -371,6 +373,30 @@ export function stripSandboxProviderEnvelope(config: SandboxEnvironmentConfig):
return driverConfig;
}
+// The host owns these sandbox run-behavior flags, not the provider plugin. The
+// host reads them to select the run-log stream and the ACP session output
+// stream. The host passes the whole config to the plugin, so a plugin that
+// allowlists its own driver fields drops these flags from its normalized
+// config. Re-apply them from the parsed envelope after the plugin normalizes,
+// or a saved environment loses the operator opt-in and the stream never starts.
+const HOST_OWNED_SANDBOX_STREAM_FLAGS = [
+ "streamRunLogs",
+ "streamAgentSessionOutput",
+] as const;
+
+function applyHostOwnedSandboxStreamFlags(
+ normalizedConfig: Record,
+ envelope: Record,
+): Record {
+ const merged: Record = { ...normalizedConfig };
+ for (const key of HOST_OWNED_SANDBOX_STREAM_FLAGS) {
+ if (envelope[key] !== undefined) {
+ merged[key] = envelope[key];
+ }
+ }
+ return merged;
+}
+
export function normalizeEnvironmentConfig(input: {
driver: EnvironmentDriver;
config: Record | null | undefined;
@@ -458,7 +484,7 @@ export function normalizeEnvironmentConfigForProbe(input: {
...(await resolveConfigSecretRefsForProbe({
db: input.db,
companyId: input.companyId,
- config: validated.normalizedConfig,
+ config: applyHostOwnedSandboxStreamFlags(validated.normalizedConfig, parsed.data),
accessContext: input.accessContext,
schema:
validated.driver.configSchema &&
@@ -550,7 +576,7 @@ export async function normalizeEnvironmentConfigForPersistence(input: {
secretProvider: input.secretProvider,
config: {
provider: parsed.data.provider,
- ...validated.normalizedConfig,
+ ...applyHostOwnedSandboxStreamFlags(validated.normalizedConfig, parsed.data),
},
schema:
validated.driver.configSchema && typeof validated.driver.configSchema === "object" && !Array.isArray(validated.driver.configSchema)
diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts
index c18b27564bd..0cfb88806d6 100644
--- a/server/src/services/environment-execution-target.ts
+++ b/server/src/services/environment-execution-target.ts
@@ -69,6 +69,28 @@ function toBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
+/**
+ * Compute the tail of `final` that the provider did NOT already stream.
+ *
+ * The provider streams output chunks in order. Those chunks form `delivered`.
+ * The final result is `final`. In the normal path `final` continues
+ * `delivered`, so the tail is `final` past the delivered length.
+ *
+ * A provider can stream a prefix and then fall back to a poll that returns a
+ * different buffer. When `final` does not start with `delivered`, a length
+ * slice would drop unrelated leading output or cut a chunk mid-text, so the
+ * durable log would hold truncated or corrupt output. In that case this
+ * function returns the whole `final` instead. That can repeat the streamed
+ * prefix in the log, but the complete final output always reaches the log.
+ * Repetition is safer than a silent loss of output.
+ */
+function undeliveredSuffix(delivered: string, final: string): string {
+ if (!final) return "";
+ if (delivered.length === 0) return final;
+ if (final.startsWith(delivered)) return final.slice(delivered.length);
+ return final;
+}
+
/**
* The closed input for one `sandbox.exec` span. The seam builds it from the
* exec result and the active step context. Every field is already bounded or
@@ -234,6 +256,10 @@ export async function resolveEnvironmentExecutionTarget(input: {
// output reaches the UI mid-run; `streamRunLogs: false` is an explicit
// opt-out back to batch-at-end delivery.
streamRunLogs: parsed.config.streamRunLogs !== false,
+ // Interactive ACP output streaming through the persistent session log
+ // stream. Default OFF: the process session bridge keeps the output-file
+ // poll unless an operator opts a sandbox environment in.
+ streamAgentSessionOutput: parsed.config.streamAgentSessionOutput === true,
runner: input.environmentRuntime && input.lease
? {
// Provider-backed sandbox RPCs do not surface bounded mid-stream
@@ -266,6 +292,27 @@ export async function resolveEnvironmentExecutionTarget(input: {
// provider execution marks the span failed. A later log-callback
// rejection sits outside this block and never flips a successful
// execution to failed.
+ // Incremental log sink. The provider streams each output chunk
+ // through the execute.log notification while the command runs.
+ // Serialize the delivery per execute call so the runner sees the
+ // chunks in order, and keep the delivered text per stream, so the
+ // final-result delivery below emits only the un-streamed suffix
+ // and can detect a provider poll fallback that returns a
+ // different buffer.
+ let incrementalLogChain: Promise = Promise.resolve();
+ let deliveredStdout = "";
+ let deliveredStderr = "";
+ const onIncrementalLog = (
+ stream: "stdout" | "stderr",
+ chunk: string,
+ ): Promise => {
+ if (stream === "stdout") deliveredStdout += chunk;
+ else deliveredStderr += chunk;
+ incrementalLogChain = incrementalLogChain.then(() =>
+ commandInput.onLog?.(stream, chunk),
+ );
+ return incrementalLogChain;
+ };
let result;
try {
result = await input.environmentRuntime!.execute({
@@ -277,6 +324,16 @@ export async function resolveEnvironmentExecutionTarget(input: {
env: commandInput.env,
stdin: commandInput.stdin,
timeoutMs: commandInput.timeoutMs,
+ onLog: commandInput.onLog ? onIncrementalLog : undefined,
+ // The ACP process session bridge sets `useSession` so its
+ // long-lived agent command opens the persistent session and
+ // streams output, even though it runs with no active step.
+ forceSession: commandInput.useSession,
+ // The bridge control-plane execs set `bypassSession` so they
+ // run one-shot and never queue behind the long-lived agent
+ // command on the persistent session. An explicit bypass wins
+ // over `forceSession` and over the active-step selection.
+ bypassSession: commandInput.bypassSession,
});
} catch (error) {
// The provider execution threw. Mark the span failed with the
@@ -322,12 +379,28 @@ export async function resolveEnvironmentExecutionTarget(input: {
// Observability must not change execution control flow.
}
}
- // Deliver the captured output. A rejected `onLog` still
- // propagates to the caller (control flow is unchanged), but the
- // span already carries the successful outcome, so a log failure
- // never marks the execution failed.
- if (result.stdout) await commandInput.onLog?.("stdout", result.stdout);
- if (result.stderr) await commandInput.onLog?.("stderr", result.stderr);
+ // Drain the ordered incremental delivery before the final
+ // result. The provider streamed chunks arrive as execute.log
+ // notifications while the command runs; awaiting the chain keeps
+ // the runner order and surfaces a log-sink rejection.
+ await incrementalLogChain;
+ // Deliver only the suffix the provider did NOT already stream.
+ // The streamed chunks usually form an in-order prefix of the
+ // final result, so the remaining output is the final text past
+ // the delivered text. When the provider streamed nothing, the
+ // whole output is the suffix. When it streamed the complete
+ // output, the suffix is empty and nothing repeats. When it
+ // streamed a prefix and then fell back to a poll whose buffer
+ // does not continue that prefix, `undeliveredSuffix` returns the
+ // whole final output, so the durable log keeps the complete
+ // result and never holds a truncated slice. A rejected `onLog`
+ // still propagates to the caller (control flow is unchanged),
+ // but the span already carries the successful outcome, so a log
+ // failure never marks the execution failed.
+ const stdoutSuffix = undeliveredSuffix(deliveredStdout, result.stdout ?? "");
+ if (stdoutSuffix) await commandInput.onLog?.("stdout", stdoutSuffix);
+ const stderrSuffix = undeliveredSuffix(deliveredStderr, result.stderr ?? "");
+ if (stderrSuffix) await commandInput.onLog?.("stderr", stderrSuffix);
return {
exitCode: result.exitCode,
signal: result.signal ?? null,
diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts
index 0e381340a9a..7bcd679739b 100644
--- a/server/src/services/environment-runtime.ts
+++ b/server/src/services/environment-runtime.ts
@@ -48,7 +48,7 @@ import {
sandboxConfigFromLeaseMetadataLoose,
} from "./sandbox-provider-runtime.js";
import { pluginRegistryService } from "./plugin-registry.js";
-import type { PluginWorkerManager } from "./plugin-worker-manager.js";
+import type { ExecuteLogSink, PluginWorkerManager } from "./plugin-worker-manager.js";
import {
destroyPluginEnvironmentLease,
executePluginEnvironmentCommand,
@@ -202,6 +202,23 @@ export interface EnvironmentDriverExecuteInput extends EnvironmentDriverLeaseInp
* span parents to the run trace. The default keeps the session path.
*/
bypassSession?: boolean;
+ /**
+ * Force the command onto the lease's persistent session even when no run step
+ * is active. The ACP process session bridge sets this so the long-lived agent
+ * command opens the session and streams its output through the session log
+ * stream. `bypassSession: true` still wins, so an explicit bypass is never
+ * overridden. The default keeps the context-based session selection.
+ */
+ forceSession?: boolean;
+ /**
+ * Incremental log sink for one execute call. When set, the plugin worker
+ * delivers each `stdout` and `stderr` chunk to this sink through the
+ * `execute.log` notification while the command runs, before the final result.
+ * The runtime forwards it to the plugin worker manager, which routes each
+ * chunk to this sink by the host-issued invocation id. A driver that does not
+ * stream ignores it and returns only the final result.
+ */
+ onLog?: ExecuteLogSink;
}
export interface EnvironmentDriverSyncInput extends EnvironmentDriverLeaseInput {
@@ -1337,7 +1354,12 @@ function createSandboxEnvironmentDriver(
// the first in-run command that carries a run parent (an agent tool
// command runs under the run trace), whose setup span parents to the run
// trace. A command that sets `bypassSession` explicitly always bypasses.
- const bypassSession = input.bypassSession === true || activeStep === null;
+ // A command that sets `forceSession` keeps the session even with no
+ // active step: the ACP process session bridge runs the long-lived agent
+ // command this way, so the session opens and streams its output through
+ // the session log stream. An explicit `bypassSession` still wins.
+ const bypassSession =
+ input.bypassSession === true || (activeStep === null && input.forceSession !== true);
const pluginId = readString(input.lease.metadata?.pluginId);
const providerKey = readString(input.lease.metadata?.provider);
if (pluginId && providerKey) {
@@ -1374,7 +1396,7 @@ function createSandboxEnvironmentDriver(
}, resolvePluginExecuteRpcTimeoutMs({
requestedTimeoutMs: input.timeoutMs,
config: sanitizedConfig,
- }));
+ }), input.onLog);
}
}
throw new Error("Sandbox driver does not support direct command execution for built-in providers.");
diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts
index e0a5cbd0d84..28d152541ec 100644
--- a/server/src/services/plugin-worker-manager.ts
+++ b/server/src/services/plugin-worker-manager.ts
@@ -107,6 +107,37 @@ const CRASH_WINDOW_MS = 10 * 60 * 1_000;
/** Maximum number of stderr characters retained for worker failure context. */
const MAX_STDERR_EXCERPT_CHARS = 8_000;
+/** Maximum characters accepted for one `execute.log` chunk. A larger chunk is
+ * dropped, so a faulty or hostile worker cannot flood the host with one
+ * unbounded notification. */
+const MAX_EXECUTE_LOG_CHUNK_CHARS = 1_000_000;
+
+/**
+ * Maximum characters accepted for one incoming worker stdout line before the
+ * host parses it as JSON. The host drops a longer line without a parse, so a
+ * faulty or hostile worker cannot force the host to parse an unbounded document
+ * and exhaust memory. The bound sits far above the largest legitimate framed
+ * message, so a real large command result still passes. A worker can override
+ * it through `WorkerStartOptions.executeLogLimits`.
+ */
+const MAX_WORKER_MESSAGE_CHARS = 128 * 1024 * 1024;
+
+/**
+ * Default ceiling for the total characters one execute call may stream through
+ * `execute.log`. The host counts the delivered characters for each active
+ * execute route and drops further chunks past this bound, so one runaway or
+ * hostile execution cannot flood the host and the run-log sink without limit.
+ * The final command result still delivers the complete output through its own
+ * capture path. A worker can override it through
+ * `WorkerStartOptions.executeLogLimits`.
+ */
+const MAX_EXECUTE_LOG_TOTAL_CHARS = 128 * 1024 * 1024;
+
+/** Minimum time between two dropped-`execute.log` debug records. The router
+ * rate-limits the record so a flood of dropped chunks writes at most one line
+ * per window with a running count. */
+const EXECUTE_LOG_DROP_LOG_INTERVAL_MS = 1_000;
+
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
@@ -239,6 +270,18 @@ export interface WorkerStartOptions {
* The host wires this to the PluginStreamBus to fan out events to SSE clients.
*/
onStreamNotification?: (method: string, params: Record) => void;
+ /**
+ * Framing and flood limits for the `execute.log` route. The defaults bound
+ * one incoming line before the JSON parse and the total streamed output for
+ * one execute call. A test overrides them to exercise the drop paths without
+ * huge inputs.
+ */
+ executeLogLimits?: {
+ /** Max characters for one incoming worker line before the JSON parse. */
+ maxIncomingMessageChars?: number;
+ /** Max total characters one execute call may stream through `execute.log`. */
+ maxTotalCharsPerExecute?: number;
+ };
}
/**
@@ -268,6 +311,42 @@ interface ActiveInvocation {
traceparent?: string;
}
+/**
+ * Sink for one incremental output chunk of an active `environmentExecute` call.
+ * The host runner passes it to `call` for the execute method, and the manager
+ * delivers each `execute.log` chunk to it. The sink may return a promise; the
+ * caller owns the ordering.
+ */
+export type ExecuteLogSink = (
+ stream: "stdout" | "stderr",
+ chunk: string,
+) => void | Promise;
+
+/**
+ * Host-owned route for one active execute call. The host mints the invocation
+ * id and stores the exact company id and log sink here. A worker never selects
+ * this record; the host looks it up by the host-issued invocation id on the
+ * message envelope. The company id is the single authority for the delivery
+ * target, so an `execute.log` notification never carries a company id.
+ */
+interface ExecuteLogRoute {
+ companyId: string;
+ onLog: ExecuteLogSink;
+ /**
+ * The count of characters delivered through this route. The router bounds the
+ * per-execute total and drops chunks past the configured ceiling.
+ */
+ deliveredChars: number;
+ /**
+ * Latched when the router cannot bind the shared worker pipe to a single
+ * company, because a second company's execute overlapped this one. After the
+ * latch the router drops every further chunk for this route and lets the final
+ * command result deliver the complete output. The latch keeps the delivered
+ * prefix contiguous, so the run log never shows a gap.
+ */
+ crossCompanyBlocked: boolean;
+}
+
// ---------------------------------------------------------------------------
// PluginWorkerHandle — manages a single worker process
// ---------------------------------------------------------------------------
@@ -316,6 +395,7 @@ export interface PluginWorkerHandle {
method: M,
params: HostToWorkerMethods[M][0],
timeoutMs?: number,
+ executeLogSink?: ExecuteLogSink,
): Promise;
/**
@@ -424,6 +504,7 @@ export interface PluginWorkerManager {
method: M,
params: HostToWorkerMethods[M][0],
timeoutMs?: number,
+ executeLogSink?: ExecuteLogSink,
): Promise;
}
@@ -460,6 +541,28 @@ export function createPluginWorkerHandle(
const pendingRequests = new Map();
let nextRequestId = 1;
const activeInvocations = new Map();
+ // Host-owned execute routes, keyed by the host-issued invocation id. Only an
+ // `environmentExecute` call with a log sink registers a route here. The
+ // `execute.log` router delivers only through this map — never through the
+ // generic `activeInvocations` record — so a non-execute call can never become
+ // a log target.
+ const activeExecuteRoutes = new Map();
+ // Rate-limit state for dropped `execute.log` notifications. The debug record
+ // never carries chunk bytes.
+ let executeLogDropCount = 0;
+ let executeLogDropLoggedAtMs = 0;
+ // Rate-limit state for dropped oversized worker lines. The warn record carries
+ // only the length, never the line bytes.
+ let oversizedLineDropCount = 0;
+ let oversizedLineLoggedAtMs = 0;
+
+ // Framing and flood limits for the `execute.log` route. The defaults bound one
+ // incoming line before the JSON parse and the total streamed output for one
+ // execute call. A caller (a test) can lower them.
+ const maxIncomingMessageChars =
+ options.executeLogLimits?.maxIncomingMessageChars ?? MAX_WORKER_MESSAGE_CHARS;
+ const maxExecuteLogTotalChars =
+ options.executeLogLimits?.maxTotalCharsPerExecute ?? MAX_EXECUTE_LOG_TOTAL_CHARS;
// ------------------------------------------------------------------
// Proactive company scopes (LOOA-629)
@@ -547,6 +650,14 @@ export function createPluginWorkerHandle(
function handleLine(line: string): void {
if (!line.trim()) return;
+ // Enforce the framing bound BEFORE the JSON parse. A line longer than the
+ // limit is dropped without a parse, so a faulty or hostile worker cannot
+ // force the host to parse an unbounded document and exhaust memory.
+ if (line.length > maxIncomingMessageChars) {
+ dropOversizedLine(line.length);
+ return;
+ }
+
let message: unknown;
try {
message = parseMessage(line);
@@ -658,6 +769,157 @@ export function createPluginWorkerHandle(
activeInvocations.delete(invocation.id);
}
+ // Store the host-owned execute route for one active execute call. The host
+ // holds the exact company id and log sink; the worker never supplies them.
+ function registerExecuteRoute(
+ invocationId: string,
+ companyId: string,
+ onLog: ExecuteLogSink,
+ ): void {
+ activeExecuteRoutes.set(invocationId, {
+ companyId,
+ onLog,
+ deliveredChars: 0,
+ crossCompanyBlocked: false,
+ });
+ }
+
+ function clearExecuteRoute(invocationId: string | undefined): void {
+ if (invocationId) activeExecuteRoutes.delete(invocationId);
+ }
+
+ // Drop an oversized incoming worker line before the JSON parse. Write a
+ // rate-limited warn record with the length and a running drop count. The
+ // record never carries the line bytes.
+ function dropOversizedLine(lineLength: number): void {
+ oversizedLineDropCount += 1;
+ const nowMs = Date.now();
+ if (nowMs - oversizedLineLoggedAtMs >= EXECUTE_LOG_DROP_LOG_INTERVAL_MS) {
+ log.warn(
+ { lineLength, maxIncomingMessageChars, droppedSinceLastLog: oversizedLineDropCount },
+ "dropping oversized worker line before JSON parse",
+ );
+ oversizedLineLoggedAtMs = nowMs;
+ oversizedLineDropCount = 0;
+ }
+ }
+
+ // Drop an `execute.log` notification. Write a rate-limited debug record with
+ // the reason and a running drop count. The record never carries the chunk
+ // bytes, the company id, or command data.
+ function dropExecuteLogNotification(reason: string): void {
+ executeLogDropCount += 1;
+ const nowMs = Date.now();
+ if (nowMs - executeLogDropLoggedAtMs >= EXECUTE_LOG_DROP_LOG_INTERVAL_MS) {
+ log.debug(
+ { reason, droppedSinceLastLog: executeLogDropCount },
+ "dropping execute.log notification",
+ );
+ executeLogDropLoggedAtMs = nowMs;
+ executeLogDropCount = 0;
+ }
+ }
+
+ // Route one `execute.log` notification to its host-owned execute route. The
+ // route is the single authority for the delivery target and the company
+ // binding. This never reads a company id from the notification and never
+ // routes through the generic active-invocation record.
+ //
+ // Complete mediation: the host and the worker share one stdio pipe, and the
+ // worker process sees every active invocation id. So the host cannot prove
+ // which concurrent invocation produced a notification, and it must NOT treat
+ // the worker-supplied `paperclipInvocationId` alone as proof of origin. The
+ // host validates the exact company scope instead: it delivers only while every
+ // active execute route on this worker belongs to ONE company. When a second
+ // company's execute overlaps, the host fails closed — it latches the active
+ // routes and drops the chunk — so a worker that runs company A can never forge
+ // company B's active id and inject output into B's route. The final command
+ // result still delivers the complete output, so no byte is lost; only the live
+ // stream pauses while two companies overlap.
+ function routeExecuteLogNotification(notification: JsonRpcNotification): void {
+ const invocationId = readNonEmptyString(
+ (notification as { paperclipInvocationId?: unknown }).paperclipInvocationId,
+ );
+ const params = isRecord(notification.params) ? notification.params : {};
+ const stream = params.stream;
+ const chunk = params.chunk;
+ // Runtime-validate the payload. Drop invalid input without a throw.
+ if (stream !== "stdout" && stream !== "stderr") {
+ dropExecuteLogNotification("invalid-stream");
+ return;
+ }
+ if (
+ typeof chunk !== "string" ||
+ chunk.length === 0 ||
+ chunk.length > MAX_EXECUTE_LOG_CHUNK_CHARS
+ ) {
+ dropExecuteLogNotification("invalid-chunk");
+ return;
+ }
+ if (!invocationId) {
+ dropExecuteLogNotification("missing-invocation");
+ return;
+ }
+ const route = activeExecuteRoutes.get(invocationId);
+ if (!route) {
+ // No active execute route for this id: a late chunk after settlement or
+ // timeout, a non-execute invocation, or an unknown id. Drop it.
+ dropExecuteLogNotification("no-active-route");
+ return;
+ }
+ // The route already lost single-company attribution earlier in its life, so
+ // it stays closed for the rest of the call.
+ if (route.crossCompanyBlocked) {
+ dropExecuteLogNotification("cross-company-scope");
+ return;
+ }
+ // Validate the exact company scope. Deliver only while every active execute
+ // route on this worker belongs to one company. A second company's active
+ // route makes the shared pipe ambiguous, so the host fails closed: it
+ // latches every active route and drops the chunk.
+ let onlyCompanyId: string | null = null;
+ let crossCompany = false;
+ for (const active of activeExecuteRoutes.values()) {
+ if (onlyCompanyId === null) {
+ onlyCompanyId = active.companyId;
+ } else if (onlyCompanyId !== active.companyId) {
+ crossCompany = true;
+ break;
+ }
+ }
+ if (crossCompany) {
+ for (const active of activeExecuteRoutes.values()) {
+ active.crossCompanyBlocked = true;
+ }
+ dropExecuteLogNotification("cross-company-scope");
+ return;
+ }
+ // Bound the total characters one execute call may stream. Past the ceiling
+ // the host drops further chunks, so one runaway or hostile execution cannot
+ // flood the host and the run-log sink without limit.
+ if (route.deliveredChars + chunk.length > maxExecuteLogTotalChars) {
+ dropExecuteLogNotification("execute-output-cap");
+ return;
+ }
+ route.deliveredChars += chunk.length;
+ try {
+ const delivery = route.onLog(stream, chunk);
+ if (delivery && typeof (delivery as Promise).then === "function") {
+ void (delivery as Promise).catch((err) => {
+ log.error(
+ { err: err instanceof Error ? err.message : String(err) },
+ "execute.log delivery failed",
+ );
+ });
+ }
+ } catch (err) {
+ log.error(
+ { err: err instanceof Error ? err.message : String(err) },
+ "execute.log delivery threw",
+ );
+ }
+ }
+
/**
* Extract the single company a worker→host call references, mirroring the SDK
* governed-access gate's own derivation (host-client-factory.ts
@@ -811,6 +1073,13 @@ export function createPluginWorkerHandle(
return;
}
+ // Execute-log notifications: deliver one incremental output chunk to the
+ // host-owned execute route for the active execute call.
+ if (notification.method === "execute.log") {
+ routeExecuteLogNotification(notification);
+ return;
+ }
+
// Stream notifications: forward to the stream bus via callback
if (
notification.method === "streams.open" ||
@@ -1273,6 +1542,7 @@ export function createPluginWorkerHandle(
method: M,
params: HostToWorkerMethods[M][0],
timeoutMs?: number,
+ executeLogSink?: ExecuteLogSink,
): Promise {
const rpcPromise = new Promise((resolve, reject) => {
if (!childProcess?.stdin?.writable) {
@@ -1288,6 +1558,13 @@ export function createPluginWorkerHandle(
const timeout = resolveRpcCallTimeoutMs(timeoutMs, rpcTimeoutMs);
const invocationScope = deriveInvocationScope(method, params);
const invocation = invocationScope ? registerInvocation(invocationScope) : null;
+ // Register the host-owned execute route only for an execute call that
+ // carries a log sink. The company id comes from the host-derived
+ // invocation scope, never from the worker. This binds the sink to the
+ // exact company for the life of the call.
+ if (invocation && invocationScope && executeLogSink && method === "environmentExecute") {
+ registerExecuteRoute(invocation.id, invocationScope.companyId, executeLogSink);
+ }
// Guard against double-settlement. When a process exits all pending
// requests are rejected via rejectAllPending(), but the timeout timer
@@ -1301,6 +1578,7 @@ export function createPluginWorkerHandle(
clearTimeout(timer);
pendingRequests.delete(id);
clearInvocation(invocation);
+ clearExecuteRoute(invocation?.id);
fn(value);
};
@@ -1343,6 +1621,7 @@ export function createPluginWorkerHandle(
clearTimeout(timer);
pendingRequests.delete(id);
clearInvocation(invocation);
+ clearExecuteRoute(invocation?.id);
reject(
new Error(
`Failed to send "${method}" to worker: ${
@@ -1396,6 +1675,7 @@ export function createPluginWorkerHandle(
method: M,
params: HostToWorkerMethods[M][0],
timeoutMs?: number,
+ executeLogSink?: ExecuteLogSink,
): Promise {
if (status !== "running" && status !== "starting") {
return Promise.reject(
@@ -1404,7 +1684,7 @@ export function createPluginWorkerHandle(
),
);
}
- return callInternal(method, params, timeoutMs);
+ return callInternal(method, params, timeoutMs, executeLogSink);
},
notify(method: string, params: unknown) {
@@ -1635,6 +1915,7 @@ export function createPluginWorkerManager(
method: M,
params: HostToWorkerMethods[M][0],
timeoutMs?: number,
+ executeLogSink?: ExecuteLogSink,
): Promise {
const handle = workers.get(pluginId);
if (!handle) {
@@ -1642,7 +1923,7 @@ export function createPluginWorkerManager(
new Error(`No worker registered for plugin "${pluginId}"`),
);
}
- return handle.call(method, params, timeoutMs);
+ return handle.call(method, params, timeoutMs, executeLogSink);
},
};
}
From 384e5f617851e96048e274beeadd75c009310f2e Mon Sep 17 00:00:00 2001
From: Tonio
Date: Fri, 7 Aug 2026 14:55:34 -0700
Subject: [PATCH 009/201] revert(ui): back out onboarding port (#10786)
(#11067)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This reverts commit 11e56654f8492f78ec56986e51be3d33f8fd6873.
#10786 ported the onboarding flow from a standalone prototype and
repointed `/onboarding` at the new `CloudOnboardingFlow`, deleting the
existing `OnboardingWizard.tsx` in the process. The ported flow is not
ready to be the shipping onboarding experience: it landed as a single
large port rather than an incremental migration, it pulled `motion`,
`three` and `@types/three` onto the UI dependency list for prototype
visuals, and it deleted the wizard that four in-flight pull requests
(#9900, #9501, #8982 and one more) were building on — those went
CONFLICTING the moment the file disappeared.
Rather than keep the half-migrated state on master while that is sorted
out, back the port out whole and re-land it incrementally. This restores
`OnboardingWizard.tsx` and the previous versions of the four e2e specs,
drops the `onboarding-preview.html` Vite entry, the DesignGuide
onboarding section and the `data-viz-misc` storybook story, and removes
the three prototype dependencies from `ui/package.json`.
This is an exact mechanical inverse of the squash commit — 41 files,
+2089/-3647, no hand edits. Reverting this commit restores all 41 files
byte for byte, so the port is recoverable in full when it is ready.
`pnpm-lock.yaml` is deliberately not touched. #10786 never updated it;
bot commit 4683f26c9 (#11036) added the `motion`/`three` entries
afterwards, so the lockfile is now ahead of the manifest. CI owns
lockfile updates (`.github/workflows/pr.yml`) and the policy job
regenerates it from the changed manifest.
Co-authored-by: Paperclip
---
.claude/launch.json | 7 -
.../references/component-index.md | 18 -
.../e2e/conference-room-typing-intro.spec.ts | 66 +-
tests/e2e/nux-phase4-screenshots.spec.ts | 143 +-
tests/e2e/onboarding-flow.ts | 134 --
tests/e2e/onboarding.spec.ts | 79 +-
.../planning-mode-visual-verification.spec.ts | 53 +-
ui/onboarding-preview.html | 22 -
ui/package.json | 5 +-
ui/src/components/AgentCapsule.tsx | 88 +-
ui/src/components/OnboardingWizard.tsx | 1789 +++++++++++++++++
.../OnboardingWizardVariant.test.tsx | 80 +-
ui/src/components/OnboardingWizardVariant.tsx | 72 +-
ui/src/components/onboarding/AgentPreview.tsx | 47 -
.../onboarding/CloudOnboardingFlow.tsx | 165 --
ui/src/components/onboarding/FooterNav.tsx | 50 -
.../onboarding/GithubStarInterstitial.tsx | 44 -
.../onboarding/LocalOnboardingFlow.tsx | 228 ---
.../onboarding/OnboardingAuthBackdrop.tsx | 102 -
.../onboarding/OnboardingAuthScreens.tsx | 163 --
.../onboarding/OnboardingPrimitives.tsx | 188 --
.../onboarding/OnboardingScaffold.tsx | 42 -
.../onboarding/PaperclipOrbit3D.tsx | 335 ---
.../components/onboarding/onboarding-data.ts | 79 -
.../onboarding/onboarding-motion.ts | 49 -
.../onboarding/steps/AdapterStep.tsx | 144 --
.../components/onboarding/steps/AgentStep.tsx | 102 -
.../onboarding/steps/CompanyStep.tsx | 87 -
.../components/onboarding/steps/EmailStep.tsx | 62 -
.../components/onboarding/steps/StartStep.tsx | 70 -
.../components/onboarding/steps/TaskStep.tsx | 131 --
ui/src/hooks/useOnboardingFlow.test.tsx | 162 --
ui/src/hooks/useOnboardingFlow.ts | 483 -----
ui/src/index.css | 74 +-
ui/src/lib/onboarding-adapter-config.test.ts | 50 -
ui/src/lib/onboarding-adapter-config.ts | 63 -
ui/src/lib/onboarding-constants.ts | 16 -
ui/src/onboarding-preview-main.tsx | 112 --
ui/src/pages/DesignGuide.tsx | 97 -
.../stories/data-viz-misc.stories.tsx | 31 +-
ui/vite.config.ts | 10 +-
41 files changed, 2092 insertions(+), 3650 deletions(-)
delete mode 100644 tests/e2e/onboarding-flow.ts
delete mode 100644 ui/onboarding-preview.html
create mode 100644 ui/src/components/OnboardingWizard.tsx
delete mode 100644 ui/src/components/onboarding/AgentPreview.tsx
delete mode 100644 ui/src/components/onboarding/CloudOnboardingFlow.tsx
delete mode 100644 ui/src/components/onboarding/FooterNav.tsx
delete mode 100644 ui/src/components/onboarding/GithubStarInterstitial.tsx
delete mode 100644 ui/src/components/onboarding/LocalOnboardingFlow.tsx
delete mode 100644 ui/src/components/onboarding/OnboardingAuthBackdrop.tsx
delete mode 100644 ui/src/components/onboarding/OnboardingAuthScreens.tsx
delete mode 100644 ui/src/components/onboarding/OnboardingPrimitives.tsx
delete mode 100644 ui/src/components/onboarding/OnboardingScaffold.tsx
delete mode 100644 ui/src/components/onboarding/PaperclipOrbit3D.tsx
delete mode 100644 ui/src/components/onboarding/onboarding-data.ts
delete mode 100644 ui/src/components/onboarding/onboarding-motion.ts
delete mode 100644 ui/src/components/onboarding/steps/AdapterStep.tsx
delete mode 100644 ui/src/components/onboarding/steps/AgentStep.tsx
delete mode 100644 ui/src/components/onboarding/steps/CompanyStep.tsx
delete mode 100644 ui/src/components/onboarding/steps/EmailStep.tsx
delete mode 100644 ui/src/components/onboarding/steps/StartStep.tsx
delete mode 100644 ui/src/components/onboarding/steps/TaskStep.tsx
delete mode 100644 ui/src/hooks/useOnboardingFlow.test.tsx
delete mode 100644 ui/src/hooks/useOnboardingFlow.ts
delete mode 100644 ui/src/lib/onboarding-adapter-config.test.ts
delete mode 100644 ui/src/lib/onboarding-adapter-config.ts
delete mode 100644 ui/src/lib/onboarding-constants.ts
delete mode 100644 ui/src/onboarding-preview-main.tsx
diff --git a/.claude/launch.json b/.claude/launch.json
index 1af7d4ce098..7cbc05c40a6 100644
--- a/.claude/launch.json
+++ b/.claude/launch.json
@@ -7,13 +7,6 @@
"runtimeArgs": ["-c", "TMPDIR=/tmp pnpm dev"],
"port": 3108,
"autoPort": false
- },
- {
- "name": "ui-preview",
- "runtimeExecutable": "pnpm",
- "runtimeArgs": ["--filter", "@paperclipai/ui", "exec", "vite"],
- "port": 5188,
- "autoPort": true
}
]
}
diff --git a/.claude/skills/design-guide/references/component-index.md b/.claude/skills/design-guide/references/component-index.md
index 93c43b1754a..88e75178b31 100644
--- a/.claude/skills/design-guide/references/component-index.md
+++ b/.claude/skills/design-guide/references/component-index.md
@@ -188,24 +188,6 @@ Use in property rows, comment headers, assignee displays, and anywhere a user/ag
**File:** `CompanySwitcher.tsx`
**Usage:** Company selector dropdown in sidebar header.
-### AgentCapsule
-
-**File:** `AgentCapsule.tsx`
-**Props:** `state: "slot" | "configured" | "online"`, `gradient?: 1–10`, `size?: "sm" | "md" | "lg" | {width,height}`, `glow?: "green" | "blue"`
-**Usage:** The brand "capsule is the agent" pill; evolves in place across onboarding steps. Fill uses `--agent-Na/Nb` gradient tokens; honors `prefers-reduced-motion`.
-
-### Onboarding primitives (OnboardingCard, OnboardingHeading, Stepper, Chip, ChoiceCard, ConnectorRow)
-
-**File:** `onboarding/OnboardingPrimitives.tsx`
-**Usage:** Presentational pieces for the full-screen onboarding flow (`onboarding/OnboardingFlow.tsx`): the 560px card frame (`--sz-560px`), display heading + lede (text-4xl), 3-segment stepper, selectable mission chips, selectable choice cards, and connector rows. Bespoke dimensions route through verbatim `--sz-*` tokens; fields inside the flow use the shared Input/Textarea/Select/Label primitives.
-
-```tsx
-
-
-
-
-```
-
---
## Layout Components
diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts
index 49364e6d8d3..e1a7096ed6b 100644
--- a/tests/e2e/conference-room-typing-intro.spec.ts
+++ b/tests/e2e/conference-room-typing-intro.spec.ts
@@ -1,25 +1,32 @@
import { test, expect } from "@playwright/test";
-import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow";
/**
- * E2E: post-onboarding launch.
+ * E2E: post-wizard onboarding launch.
*
- * Completing the onboarding flow creates the first assigned task and lands the
- * user on the company dashboard. The chat intro still has unit coverage in
- * BoardChat tests; the onboarding handoff no longer routes there.
+ * Completing the onboarding wizard now creates the first assigned task and
+ * lands the user on the company dashboard. The chat intro still has unit
+ * coverage in BoardChat tests; the wizard handoff no longer routes there.
*/
const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`;
-const MISSION = "Verify the dashboard launch survives the onboarding handoff.";
+const MISSION = "Verify the dashboard launch survives the wizard handoff.";
+const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan";
-test.describe("Dashboard launch after onboarding", () => {
+test.describe("Dashboard launch after onboarding wizard", () => {
test("creates the first task and opens the dashboard", async ({
page,
baseURL,
}) => {
+ // Intercept env-test → instant pass (avoid running a real CLI check).
+ await page.route("**/test-environment", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ status: "pass", checks: [] }),
+ }),
+ );
+
// Intercept hire → perform a REAL hire server-side with an inert http
- // adapter so no real agent process spawns. (The cloud flow hires with
- // requireEnvProbe: false, so there is no adapter-environment probe to stub.)
+ // adapter so no real agent process spawns.
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
@@ -47,13 +54,38 @@ test.describe("Dashboard launch after onboarding", () => {
await page.goto("/onboarding");
- // Welcome → company (name + mission) → agent (role picker) → first task.
- // "Get started" on the task step creates the task and opens the dashboard.
- await completeCloudOnboarding(page, {
- companyName: COMPANY_NAME,
- mission: MISSION,
- choice: "hiring",
+ // Launcher card path (existing companies) — enter the wizard if the
+ // route shows a launcher instead of opening the wizard directly.
+ const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
+ if (await startBtn.count()) await startBtn.first().click();
+
+ // Step 0: front door (skipped when the wizard opens on the create path).
+ const frontDoor = page.getByText("Build a new company");
+ if (await frontDoor.count()) await frontDoor.first().click();
+
+ // Step 1: company name.
+ await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
+ await page.getByRole("button", { name: /^Next/ }).click();
+
+ // Step 2: mission (direct path default).
+ await page
+ .getByPlaceholder("What is your team trying to achieve?")
+ .fill(MISSION);
+ await page.getByRole("button", { name: /Confirm mission/ }).click();
+
+ // Step 3: lead name (prefilled) → Next.
+ await page.waitForSelector('input[placeholder="Chief of staff"]', {
+ timeout: 15_000,
});
+ await page.getByRole("button", { name: /^Next/ }).click();
+
+ // Step 4: adapter (claude_local default); heartbeat is intercepted.
+ await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
+
+ // Step 5: review → Get started creates the first task and opens dashboard.
+ const getStarted = page.getByRole("button", { name: /Get started/ });
+ await getStarted.waitFor({ timeout: 20_000 });
+ await getStarted.click();
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
@@ -66,8 +98,8 @@ test.describe("Dashboard launch after onboarding", () => {
const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
expect(issuesRes.ok()).toBe(true);
const issues = await issuesRes.json();
- const firstTask = issues.find((candidate: { title: string }) => candidate.title === HIRING_TASK_TITLE);
+ const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE);
expect(firstTask).toBeTruthy();
- await expect(page.getByText(HIRING_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
});
});
diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts
index 9f788f7c688..4c3a3481bea 100644
--- a/tests/e2e/nux-phase4-screenshots.spec.ts
+++ b/tests/e2e/nux-phase4-screenshots.spec.ts
@@ -2,7 +2,6 @@ import { test, expect } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
-import { DEFAULT_ROLE, startCloudOnboarding } from "./onboarding-flow";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -10,43 +9,40 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
* NUX Phase 4 — visual QA screenshot capture.
*
* Boots a throwaway local_trusted instance (see playwright.config.ts webServer)
- * and captures screenshots of every integrated onboarding surface:
- * - Welcome screen (path picker)
- * - Company step (name + mission)
- * - Create-your-first-agent step (role picker + capsule)
- * - First-task step
- * - "Add an agent to an existing company" entry (/:prefix/onboarding)
+ * and captures screenshots of every surface integrated by NUX Phases 1–3:
+ * - "Build a new company" step 1 (company name) + step 2 (mission)
+ * - Team-lead hire step (capsule wizard, PAP-125)
+ * - Onboarding front door (path picker)
+ * - "Add agents to your org" growth intake
* - Conference Room (BoardChat) shell + composer + activity feed
* - Artifacts page
*
* These are structural/rendering checks — LLM-dependent streaming (CEO chat
* responses, hiring-plan generation) is verified separately on an LLM-backed
- * instance. Screenshots land in ./test-results for upload as evidence.
+ * instance. Screenshots land in ./nux-phase4-shots for upload as evidence.
*/
// Write under the gitignored test-results dir so re-runs leave no untracked
// noise; screenshots are uploaded to the issue as QA evidence, not committed.
const SHOT_DIR = path.join(__dirname, "test-results", "nux-phase4-shots");
-const SHOTS = [
- "01-welcome.png",
- "02-company.png",
- "03-agent.png",
- "04-first-task.png",
- "05-add-agent.png",
- "06-board-chat.png",
- "07-artifacts.png",
-];
-
function shot(name: string) {
fs.mkdirSync(SHOT_DIR, { recursive: true });
return path.join(SHOT_DIR, name);
}
+async function openWizard(page: import("@playwright/test").Page) {
+ await page.goto("/onboarding");
+ const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ });
+ if (await startBtn.count()) {
+ await startBtn.first().click();
+ }
+}
+
test.describe("NUX Phase 4 visual QA", () => {
test("captures every integrated surface", async ({ page }) => {
- // Conference Room is flag-gated default-OFF: turn the experimental flag on
- // for this throwaway instance before driving that surface (Section C).
+ // New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
+ // experimental flag on for this throwaway instance before driving them.
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
data: { enableConferenceRoomChat: true },
});
@@ -61,43 +57,36 @@ test.describe("NUX Phase 4 visual QA", () => {
const baseUrl =
"http://127.0.0.1:" + (process.env.PAPERCLIP_E2E_PORT ?? "3199");
- // ── Section A: the cloud flow, step by step ───────────────────────────
- await page.goto("/onboarding");
-
+ // ── Section A: create-company path (name → mission → hire) ────────────
+ await openWizard(page);
+ // Front door shows when the wizard doesn't open directly on the create
+ // path (e.g. another spec already created a company on this instance).
+ const createCard = page.getByRole("button", { name: /Build a new company/ });
+ if (await createCard.count()) {
+ await createCard.first().click();
+ }
await expect(
- page.getByRole("heading", { name: "Welcome to Paperclip!" }),
+ page.getByRole("heading", { name: "Name your company" }),
).toBeVisible({ timeout: 15_000 });
- await page.screenshot({ path: shot("01-welcome.png") });
+ await page.getByPlaceholder("Acme Corp").fill("QA Robotics");
+ await page.screenshot({ path: shot("02-create-name.png") });
- await startCloudOnboarding(page);
-
- // Capture the company step populated but not yet submitted, then submit.
- await expect(
- page.getByRole("heading", { name: "What is the name of your company or team?" }),
- ).toBeVisible({ timeout: 15_000 });
- await page.locator("#onboarding-company-name").fill("QA Robotics");
- await page
- .locator("#onboarding-mission")
- .fill("Build affordable home robots that handle household chores.");
- await page.screenshot({ path: shot("02-company.png") });
await page.getByRole("button", { name: /^Next/ }).click();
-
- // Agent step: pick a role so the capsule + preview render, then capture
- // before hiring.
await expect(
- page.getByRole("heading", { name: "Create your first agent" }),
- ).toBeVisible({ timeout: 30_000 });
- await page.locator("#onboarding-agent-role").click();
- await page.getByRole("option", { name: DEFAULT_ROLE, exact: true }).click();
- await page.screenshot({ path: shot("03-agent.png") });
- await page.getByRole("button", { name: /^Create/ }).click();
+ page.getByRole("heading", { name: "Define your mission" }),
+ ).toBeVisible({ timeout: 10_000 });
+ await page
+ .getByPlaceholder("What is your team trying to achieve?")
+ .fill("Build affordable home robots that handle household chores.");
+ await page.screenshot({ path: shot("03-create-mission.png") });
- // First-task step: select a choice so the card's selected state is visible.
- await expect(
- page.getByRole("heading", { name: "Assign your agent a first task" }),
- ).toBeVisible({ timeout: 30_000 });
- await page.getByRole("button", { name: /Create a hiring plan/ }).click();
- await page.screenshot({ path: shot("04-first-task.png") });
+ // Step 2 advances via "Confirm mission" (creates the company + goal);
+ // step 3 is the team-lead naming step of the capsule wizard.
+ await page.getByRole("button", { name: /Confirm mission/ }).click();
+ await page.waitForSelector('input[placeholder="Chief of staff"]', {
+ timeout: 30_000,
+ });
+ await page.screenshot({ path: shot("04-hire-team-lead.png") });
// The company just created anchors the route-scoped sections below.
const companiesRes = await page.request.get(`${baseUrl}/api/companies`);
@@ -106,23 +95,39 @@ test.describe("NUX Phase 4 visual QA", () => {
const qaCompany = (Array.isArray(companies) ? companies : []).find(
(c: { name: string }) => c.name === "QA Robotics",
);
- expect(qaCompany, "onboarding should have created QA Robotics").toBeTruthy();
+ expect(qaCompany, "wizard should have created QA Robotics").toBeTruthy();
const prefix: string = qaCompany.issuePrefix;
- // ── Section B: "add an agent to an existing company" entry ────────────
- // OnboardingWizardVariant renders outside (App.tsx), so it never
- // sees the :companyPrefix param and the company-scoped route still opens on
- // the welcome screen. The real existing-company entry is the launcher card
- // behind it: dismiss the overlay, then "Add Agent" opens onboarding scoped
- // to this company, which skips company creation and starts at the agent step.
+ // ── Section B: front door + growth intake ─────────────────────────────
await page.evaluate(() => window.localStorage.clear());
- await page.goto(`/${prefix}/onboarding`);
- await page.getByRole("button", { name: "Close onboarding" }).click();
- await page.getByRole("button", { name: "Add Agent" }).click();
+ await openWizard(page);
+ // Reach the full-screen front door (step 0): either it shows directly or
+ // "← Back to start" returns to it from the create step.
+ if (!(await page.getByRole("heading", { name: "Welcome to Paperclip" }).count())) {
+ await page.getByRole("button", { name: /Back to start/ }).click();
+ }
await expect(
- page.getByRole("heading", { name: "Create your first agent" }),
- ).toBeVisible({ timeout: 20_000 });
- await page.screenshot({ path: shot("05-add-agent.png") });
+ page.getByRole("heading", { name: "Welcome to Paperclip" }),
+ ).toBeVisible({ timeout: 10_000 });
+ await expect(
+ page.getByRole("heading", { name: "Build a new company" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Add agents to your org" }),
+ ).toBeVisible();
+ await page.screenshot({ path: shot("01-front-door.png") });
+
+ await page.getByRole("button", { name: /Add agents to your org/ }).click();
+ // The grow path shares step 1 (company name) before its step-2 intake.
+ await expect(
+ page.getByRole("heading", { name: "Name your company" }),
+ ).toBeVisible({ timeout: 10_000 });
+ await page.getByPlaceholder("Acme Corp").fill("QA Robotics Grow");
+ await page.getByRole("button", { name: /^Next/ }).click();
+ await expect(
+ page.getByRole("heading", { name: /Tell us about your team/ }),
+ ).toBeVisible({ timeout: 10_000 });
+ await page.screenshot({ path: shot("05-growth-intake.png") });
// ── Section C: Conference Room (BoardChat) ────────────────────────────
// Visit the company dashboard first so CompanyContext selects the company
@@ -147,7 +152,15 @@ test.describe("NUX Phase 4 visual QA", () => {
await page.waitForTimeout(1_000);
await page.screenshot({ path: shot("07-artifacts.png") });
- for (const f of SHOTS) {
+ for (const f of [
+ "01-front-door.png",
+ "02-create-name.png",
+ "03-create-mission.png",
+ "04-hire-team-lead.png",
+ "05-growth-intake.png",
+ "06-board-chat.png",
+ "07-artifacts.png",
+ ]) {
const p = shot(f);
expect(fs.existsSync(p), `missing ${f}`).toBe(true);
expect(fs.statSync(p).size, `empty ${f}`).toBeGreaterThan(1_000);
diff --git a/tests/e2e/onboarding-flow.ts b/tests/e2e/onboarding-flow.ts
deleted file mode 100644
index 49f4ffb60e9..00000000000
--- a/tests/e2e/onboarding-flow.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { expect, type Page } from "@playwright/test";
-
-/**
- * Shared driver for the cloud onboarding flow (ui/src/components/onboarding/).
- *
- * The flow replaced the retired OnboardingWizard, which had a front door, a
- * separate company-name step, a separate mission step, an adapter step and a
- * review step. The cloud flow is four screens:
- *
- * start — "Welcome to Paperclip!" (unnumbered)
- * company — name + mission on one card; "Next" creates the company + goal
- * agent — role picker (+ optional name); "Create" hires the lead agent
- * task — first-task choice; "Get started" launches it and opens the dashboard
- *
- * Four specs drove the old wizard's selectors, so the step drivers live here
- * rather than being copy-pasted: when the flow changes again, this is the one
- * file to update.
- */
-
-/** Default role picked in the agent step. */
-export const DEFAULT_ROLE = "Chief of Staff";
-
-/**
- * Name the agent step auto-fills when DEFAULT_ROLE is picked. Selecting a role
- * populates the (optional) name field with the role's acronym — see
- * ROLE_ACRONYMS in ui/src/components/onboarding/onboarding-data.ts.
- */
-export const DEFAULT_ROLE_ACRONYM = "COS";
-
-/** Title of the task created by the "hiring" first-task choice. */
-export const HIRING_TASK_TITLE = "Hire your first engineer and create a hiring plan";
-
-export type FirstTaskChoice = "hiring" | "strategy" | "custom";
-
-/**
- * Matches each first-task ChoiceCard. The cards are buttons whose accessible
- * name is title + description, so these match on the title fragment only.
- */
-const CHOICE_CARD: Record = {
- hiring: /Create a hiring plan/,
- strategy: /Write a team strategy doc/,
- // Trailing ellipsis in the UI copy is deliberately not matched.
- custom: /Write your own task/,
-};
-
-/** Step 0 — the welcome screen. Advances to the first numbered step. */
-export async function startCloudOnboarding(page: Page): Promise {
- await expect(page.getByRole("heading", { name: "Welcome to Paperclip!" })).toBeVisible({
- timeout: 15_000,
- });
- await page
- .getByRole("button", { name: /Set up Paperclip for your company or team/ })
- .click();
-}
-
-/**
- * Step 1 — company name + mission on a single card. "Next" persists the company
- * and its company-level goal, then advances to the agent step.
- */
-export async function completeCompanyStep(
- page: Page,
- { companyName, mission }: { companyName: string; mission: string },
-): Promise {
- await expect(
- page.getByRole("heading", { name: "What is the name of your company or team?" }),
- ).toBeVisible({ timeout: 15_000 });
- await page.locator("#onboarding-company-name").fill(companyName);
- await page.locator("#onboarding-mission").fill(mission);
- await page.getByRole("button", { name: /^Next/ }).click();
-}
-
-/**
- * Step 2 — role picker (a Radix select) plus an optional name. "Create" hires
- * the lead agent. Pass `name` to override the acronym the role auto-fills.
- */
-export async function completeAgentStep(
- page: Page,
- { role = DEFAULT_ROLE, name }: { role?: string; name?: string } = {},
-): Promise {
- await expect(page.getByRole("heading", { name: "Create your first agent" })).toBeVisible({
- timeout: 30_000,
- });
- await page.locator("#onboarding-agent-role").click();
- await page.getByRole("option", { name: role, exact: true }).click();
- if (name !== undefined) {
- await page.locator("#onboarding-agent-name").fill(name);
- }
- await page.getByRole("button", { name: /^Create/ }).click();
-}
-
-/**
- * Step 3 — pick the first task and launch it. "Get started" creates the task
- * and navigates to the company dashboard.
- */
-export async function completeTaskStep(
- page: Page,
- { choice = "hiring", customTask }: { choice?: FirstTaskChoice; customTask?: string } = {},
-): Promise {
- await expect(
- page.getByRole("heading", { name: "Assign your agent a first task" }),
- ).toBeVisible({ timeout: 30_000 });
- await page.getByRole("button", { name: CHOICE_CARD[choice] }).click();
- if (choice === "custom") {
- await page.getByPlaceholder("Describe the first task").fill(customTask ?? "");
- }
- await page.getByRole("button", { name: /Get started/ }).click();
-}
-
-/**
- * Drives the whole cloud flow from an already-loaded /onboarding route through
- * to the dashboard navigation. Callers that need to assert or screenshot
- * between steps should call the individual step drivers instead.
- */
-export async function completeCloudOnboarding(
- page: Page,
- {
- companyName,
- mission,
- role = DEFAULT_ROLE,
- choice = "hiring",
- customTask,
- }: {
- companyName: string;
- mission: string;
- role?: string;
- choice?: FirstTaskChoice;
- customTask?: string;
- },
-): Promise {
- await startCloudOnboarding(page);
- await completeCompanyStep(page, { companyName, mission });
- await completeAgentStep(page, { role });
- await completeTaskStep(page, { choice, customTask });
-}
diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts
index f5a734d7772..eb46e76e50a 100644
--- a/tests/e2e/onboarding.spec.ts
+++ b/tests/e2e/onboarding.spec.ts
@@ -1,46 +1,79 @@
import { test, expect } from "@playwright/test";
-import { completeCompanyStep, startCloudOnboarding } from "./onboarding-flow";
/**
- * E2E: cloud onboarding flow.
+ * E2E: Onboarding wizard flow (NUX Phase 2 expanded wizard).
*
- * The flow opens on a welcome screen and then runs three numbered steps:
- * Step 0 — Welcome (unnumbered path picker)
- * Step 1 — Company name + mission
- * Step 2 — Create your first agent (role picker)
- * Step 3 — Assign your agent a first task
+ * The wizard now opens on a front door (path picker) and the "Create a new
+ * company" path runs:
+ * Step 0 — Front door (Create a new company / Level up existing)
+ * Step 1a — Name your company
+ * Step 1b — Define your mission (direct or guided)
+ * Step 2 — Hire your team lead (adapter picker)
+ * Step 3+ — Launch celebration → CEO chat → hiring plan → orientation
*
- * This test covers the deterministic, LLM-free core: it drives the welcome
- * screen through the company step (which creates the company and a
- * company-level goal) and verifies the flow advances to the agent step.
+ * This test covers the deterministic, LLM-free core: it drives the front door
+ * through company naming + mission definition (which creates the company and a
+ * company-level goal) and verifies the wizard advances to the team-lead step.
*
- * The tail (hiring the agent, launching the first task) is covered by
- * conference-room-typing-intro.spec.ts; surface-level rendering of every step
- * is snapshotted by nux-phase4-screenshots.spec.ts.
+ * The tail (CEO chat at step 4, hiring-plan generation at step 5, final
+ * landing) depends on a live LLM and is verified separately during manual /
+ * LLM-backed QA — see PAP-50. Surface-level rendering of every step is
+ * snapshotted by nux-phase4-screenshots.spec.ts.
*/
const COMPANY_NAME = `E2E-Test-${Date.now()}`;
const MISSION = "Build affordable home robots that handle household chores.";
-test.describe("Onboarding flow", () => {
- test("company step: name + mission creates company and goal", async ({
+test.describe("Onboarding wizard", () => {
+ test("create-company path: name + mission creates company and goal", async ({
page,
}) => {
const pageErrors: string[] = [];
page.on("pageerror", (err) => pageErrors.push(err.message));
+ // New-NUX surfaces are flag-gated default-OFF (PAP-136/137/138): turn the
+ // experimental flag on for this throwaway instance before driving them.
+ const flagRes = await page.request.patch("/api/instance/settings/experimental", {
+ data: { enableConferenceRoomChat: true },
+ });
+ expect(flagRes.ok()).toBe(true);
+
await page.goto("/onboarding");
- await startCloudOnboarding(page);
- await completeCompanyStep(page, {
- companyName: COMPANY_NAME,
- mission: MISSION,
+ // The wizard may open on a launcher card or directly on the capsule
+ // wizard; the front door (step 0) requires a click into the create path.
+ const startBtn = page.getByRole("button", {
+ name: /Start Onboarding|New Company|Add Agent/,
});
+ if (await startBtn.count()) {
+ await startBtn.first().click();
+ }
+ const createCard = page.getByRole("button", { name: /Build a new company/ });
+ if (await createCard.count()) {
+ await createCard.first().click();
+ }
- // Reaching the agent step means the company + goal writes succeeded.
+ // Step 1 — Name your company.
await expect(
- page.getByRole("heading", { name: "Create your first agent" }),
- ).toBeVisible({ timeout: 30_000 });
+ page.getByRole("heading", { name: "Name your company" }),
+ ).toBeVisible({ timeout: 15_000 });
+ await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
+ await page.getByRole("button", { name: /^Next/ }).click();
+
+ // Step 2 — Define your mission (direct entry is the default path).
+ await expect(
+ page.getByRole("heading", { name: "Define your mission" }),
+ ).toBeVisible({ timeout: 10_000 });
+ await page
+ .getByPlaceholder("What is your team trying to achieve?")
+ .fill(MISSION);
+
+ // "Confirm mission" creates the company + a company-level goal, then
+ // advances to the team-lead naming step of the capsule wizard.
+ await page.getByRole("button", { name: /Confirm mission/ }).click();
+ await page.waitForSelector('input[placeholder="Chief of staff"]', {
+ timeout: 30_000,
+ });
// Verify the company + company-level goal were persisted.
const baseUrl = page.url().split("/").slice(0, 3).join("/");
@@ -62,7 +95,7 @@ test.describe("Onboarding flow", () => {
);
expect(companyGoal, "a company-level goal should be created").toBeTruthy();
- // The flow must not crash the app (Rules-of-Hooks regression).
+ // The expanded wizard must not crash the app (Rules-of-Hooks regression).
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
});
});
diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts
index 9692986b935..65be76312de 100644
--- a/tests/e2e/planning-mode-visual-verification.spec.ts
+++ b/tests/e2e/planning-mode-visual-verification.spec.ts
@@ -1,21 +1,20 @@
-import path from "node:path";
-import { fileURLToPath } from "node:url";
import { expect, test } from "@playwright/test";
-import { completeCloudOnboarding, HIRING_TASK_TITLE } from "./onboarding-flow";
-const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const AGENT_NAME = "Chief of staff";
+const TASK_TITLE = "Hire your first engineer and create a hiring plan";
test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const timestamp = Date.now();
const companyName = `PAP-3413-${timestamp}`;
- // Resolve against this file, not the cwd, so screenshots land in the
- // gitignored tests/e2e/test-results/ rather than an untracked dir at the
- // repo root that a contributor could commit by accident.
- const screenshotDir = path.join(__dirname, "test-results", "planning-mode");
+ const screenshotDir = "test-results/planning-mode";
+
+ await page.route("**/test-environment", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ status: "pass", checks: [] }),
+ }),
+ );
- // Intercept hire → perform a REAL hire server-side with an inert http adapter
- // so no real agent process spawns. (The cloud flow hires with
- // requireEnvProbe: false, so there is no adapter-environment probe to stub.)
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
@@ -42,15 +41,31 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
});
await page.goto("/onboarding");
+ const startBtn = page.getByRole("button", { name: /Start Onboarding|New Company|Add Agent/ });
+ if (await startBtn.count()) await startBtn.first().click();
- // This spec only needs a company with a seeded first task to screenshot the
- // planning-mode UI against; drive the whole onboarding flow to get one.
- await completeCloudOnboarding(page, {
- companyName,
- mission: "Capture planning mode visual evidence for the graduated task UI.",
- choice: "hiring",
- });
+ const createCard = page.getByRole("button", { name: /Build a new company/ });
+ if (await createCard.count()) await createCard.first().click();
+ await expect(page.getByRole("heading", { name: "Name your company" })).toBeVisible({ timeout: 15_000 });
+
+ await page.locator('input[placeholder="Acme Corp"]').fill(companyName);
+ await page.getByRole("button", { name: /^Next/ }).click();
+
+ await expect(page.getByRole("heading", { name: "Define your mission" })).toBeVisible({ timeout: 30_000 });
+ await page
+ .getByPlaceholder("What is your team trying to achieve?")
+ .fill("Capture planning mode visual evidence for the graduated task UI.");
+ await page.getByRole("button", { name: /Confirm mission/ }).click();
+
+ await page.waitForSelector('input[placeholder="Chief of staff"]', { timeout: 30_000 });
+ await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME);
+
+ await page.getByRole("button", { name: /^Next/ }).click();
+ await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
+
+ await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 });
+ await page.getByRole("button", { name: /Get started/ }).click();
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
const baseOrigin = new URL(page.url()).origin;
@@ -64,7 +79,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const issues = await issueRes.json();
const planningSeedIssue = issues.find(
(candidate: { id: string; identifier?: string; title: string }) =>
- candidate.title === HIRING_TASK_TITLE,
+ candidate.title === TASK_TITLE,
);
expect(planningSeedIssue).toBeTruthy();
diff --git a/ui/onboarding-preview.html b/ui/onboarding-preview.html
deleted file mode 100644
index 93a17b04b86..00000000000
--- a/ui/onboarding-preview.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
- Onboarding Preview
-
-
-
-
-
-
-
-
diff --git a/ui/package.json b/ui/package.json
index 6788beded3f..ce25a72fb5b 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -59,7 +59,6 @@
"lexical": "0.48.0",
"lucide-react": "^0.577.0",
"mermaid": "^11.16.0",
- "motion": "^12.42.2",
"radix-ui": "^1.6.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -68,8 +67,7 @@
"react-resizable-panels": "^4.12.2",
"react-router-dom": "^7.18.1",
"remark-gfm": "^4.0.1",
- "tailwind-merge": "^3.6.0",
- "three": "^0.185.1"
+ "tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@storybook/addon-a11y": "10.5.4",
@@ -79,7 +77,6 @@
"@types/node": "^22.20.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
- "@types/three": "^0.185.1",
"@vitejs/plugin-react": "^4.3.4",
"storybook": "10.5.5",
"tailwindcss": "^4.3.2",
diff --git a/ui/src/components/AgentCapsule.tsx b/ui/src/components/AgentCapsule.tsx
index d671fb1ca08..817a9dbb76a 100644
--- a/ui/src/components/AgentCapsule.tsx
+++ b/ui/src/components/AgentCapsule.tsx
@@ -1,5 +1,4 @@
import * as React from "react";
-import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
@@ -9,14 +8,14 @@ import { cn } from "@/lib/utils";
* A single agent is drawn as a tall pill (proportion 1:≥2, radius 9999px)
* that moves through three states as the agent comes to life:
*
- * - `slot` — static dashed outline. An empty agent slot.
+ * - `slot` — dashed outline, gently pulsing. An empty agent slot.
* - `configured` — solid stroke. Agent named / model picked, not yet live.
- * - `online` — brand agent-gradient fill radiates outward from the center,
+ * - `online` — brand agent-gradient liquid rises to fill the capsule,
* which then breathes with an online-pulse ring (green by
* default, or blue via `glow="blue"`).
*
* The three states are drawn as stacked layers (a dashed outline, a solid
- * stroke, and the radial fill) that cross-fade by opacity. Because
+ * stroke, and the rising liquid) that cross-fade by opacity. Because
* `border-style` is not animatable, the dashed→solid morph is realized as the
* dashed layer fading out while the solid layer fades in — so the SAME capsule
* can evolve in place across a flow (PAP-125, Option 4 wizard).
@@ -25,7 +24,7 @@ import { cn } from "@/lib/utils";
* `--agent-Na` (top) → `--agent-Nb` (bottom); pick which one with `gradient`
* (1–10). Size is a preset (`sm` | `md` | `lg`) or an explicit pixel pair so
* the component is reusable app-wide. `prefers-reduced-motion` is honored in
- * CSS — the radial fill, layer cross-fade and both pulses are skipped and the
+ * CSS — the liquid rise, layer cross-fade and both pulses are skipped and the
* final state is rendered statically.
*/
@@ -61,21 +60,10 @@ export interface AgentCapsuleProps
size?: AgentCapsuleSizePreset | { width: number; height: number };
/** Online-pulse colour (only applies in the `online` state). Defaults to `green`. */
glow?: AgentCapsuleGlow;
- /**
- * Slot→configured morph as a draw-on: the solid outline is traced around the
- * perimeter over the still-visible dashed outline (which fades once the draw
- * completes), instead of the default border cross-fade. Honors reduced
- * motion by rendering the final state instantly.
- */
- strokeDraw?: boolean;
/** Accessible label; defaults to a description of the state. */
"aria-label"?: string;
}
-/** Duration of the strokeDraw perimeter trace; the dashed layer fades after it. */
-const STROKE_DRAW_SECONDS = 0.9;
-const STROKE_DRAW_EASE = [0.16, 1, 0.3, 1] as const;
-
/** Normalize a (possibly out-of-range) gradient index to 1…AGENT_GRADIENT_COUNT. */
function normalizeGradient(gradient: number): number {
const n = Math.trunc(gradient);
@@ -87,7 +75,6 @@ export function AgentCapsule({
gradient = 1,
size = "md",
glow = "green",
- strokeDraw = false,
className,
style,
"aria-label": ariaLabel,
@@ -96,8 +83,6 @@ export function AgentCapsule({
const dims = typeof size === "string" ? SIZE_PRESETS[size] : size;
const idx = normalizeGradient(gradient);
const fill = `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`;
- const reducedMotion = useReducedMotion();
- const drawn = state === "configured" || state === "online";
return (
- {/* Dashed outline — an empty agent slot. Visible (static) only in the
- slot state; cross-fades out as the capsule is configured. In
- strokeDraw mode it instead stays put while the solid outline is
- traced over it, then fades once the draw completes. */}
+ {/* Dashed outline — an empty agent slot. Visible (and pulsing) only in
+ the slot state; cross-fades out as the capsule is configured. */}
- {/* Solid stroke — agent configured, not yet live. Default: cross-fades
- in on top of the dashed layer. strokeDraw: an SVG outline traced
- around the perimeter (pathLength 0→1) over the dashed layer. */}
- {strokeDraw ? (
-
-
-
- ) : (
-
- )}
- {/* Brand-gradient fill — radiates outward from the center when online. */}
+ {/* Solid stroke — agent configured, not yet live. Cross-fades in on top
+ of the dashed layer, then out as the liquid rises. */}
+
+ {/* Brand-gradient liquid — rises to fill the capsule when online. */}
{state === "online" ? (
) : null}
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx
new file mode 100644
index 00000000000..d22203df82e
--- /dev/null
+++ b/ui/src/components/OnboardingWizard.tsx
@@ -0,0 +1,1789 @@
+import { useEffect, useState, useMemo } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
+import { useLocation, useNavigate, useParams } from "@/lib/router";
+import { useDialog } from "../context/DialogContext";
+import { useCompany } from "../context/CompanyContext";
+import { companiesApi } from "../api/companies";
+import { goalsApi } from "../api/goals";
+import { agentsApi } from "../api/agents";
+import { approvalsApi } from "../api/approvals";
+import { issuesApi } from "../api/issues";
+import { projectsApi } from "../api/projects";
+import { queryKeys } from "../lib/queryKeys";
+import { Dialog, DialogPortal } from "@/components/ui/dialog";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { cn } from "../lib/utils";
+import {
+ extractModelName,
+ extractProviderIdWithFallback
+} from "../lib/model-utils";
+import { getUIAdapter } from "../adapters";
+import { listUIAdapters } from "../adapters";
+import { isVisualAdapterChoice } from "../adapters/metadata";
+import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
+import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
+import { getAdapterDisplay } from "../adapters/adapter-display-registry";
+import { defaultCreateValues } from "./agent-config-defaults";
+import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
+import { composeCeoInstructions } from "../lib/ceo-instructions";
+import {
+ buildOnboardingIssuePayload,
+ buildOnboardingProjectPayload,
+ selectDefaultCompanyGoalId,
+ selectReusableOnboardingProject,
+} from "../lib/onboarding-launch";
+import { buildNewAgentRuntimeConfig } from "../lib/new-agent-runtime-config";
+import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
+import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
+import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local";
+import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
+import { AsciiArtAnimation } from "./AsciiArtAnimation";
+import { FrontDoor } from "./FrontDoor";
+import { AgentCapsule } from "./AgentCapsule";
+import { Badge } from "@/components/ui/badge";
+import {
+ Building2,
+ Bot,
+ ListTodo,
+ ArrowLeft,
+ ArrowRight,
+ Sparkles,
+ Check,
+ Loader2,
+ ChevronDown,
+ X
+} from "lucide-react";
+
+type Step = 0 | 1 | 2 | 3 | 4 | 5;
+// Plugin/external adapters use arbitrary type ids, so this mirrors the master
+// wizard's registry-driven approach rather than a fixed union.
+type AdapterType = string;
+
+const MISSION_PROMPT_CHIPS = [
+ "Build a SaaS product",
+ "Scale a content business",
+ "Launch a marketplace"
+];
+
+function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: string): string {
+ const parts: string[] = [];
+ if (q1.trim()) parts.push(q1.trim());
+ if (q2.trim()) parts.push(`We serve ${q2.trim().toLowerCase()}.`);
+ if (q3.trim()) parts.push(`Our biggest challenge is ${q3.trim().toLowerCase()}.`);
+ if (q4.trim()) parts.push(`Success looks like ${q4.trim().toLowerCase()}.`);
+ return parts.join(" ");
+}
+
+const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
+const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
+const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
+
+- hire a founding engineer
+- write a hiring plan
+- break the roadmap into concrete tasks and start delegating work`;
+const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
+ "Onboarding state is incomplete. Please restart onboarding and try again.";
+
+function loadSavedState(): Record
| null {
+ try {
+ const raw = localStorage.getItem(ONBOARDING_STORAGE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function OnboardingWizard() {
+ const {
+ onboardingOpen,
+ onboardingOptions,
+ closeOnboarding,
+ onboardingRouteDismissed: routeDismissed,
+ setOnboardingRouteDismissed: setRouteDismissed,
+ } = useDialog();
+ const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany();
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const { companyPrefix } = useParams<{ companyPrefix?: string }>();
+
+ // Support opening the wizard from a route (e.g. /onboarding or an existing
+ // company's "add agent" entry point) in addition to the dialog context.
+ const routeOnboardingOptions =
+ companyPrefix && companiesLoading
+ ? null
+ : resolveRouteOnboardingOptions({
+ pathname: location.pathname,
+ companyPrefix,
+ companies,
+ });
+ const effectiveOnboardingOpen =
+ onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed);
+ const effectiveOnboardingOptions = onboardingOpen
+ ? onboardingOptions
+ : routeOnboardingOptions ?? {};
+
+ // Sync disabled adapter types only when the wizard is visible. The wizard is
+ // mounted globally, including on /auth, where protected adapter routes are
+ // expected to reject signed-out browsers.
+ const disabledTypes = useDisabledAdaptersSync({ enabled: effectiveOnboardingOpen });
+
+ const initialStep = effectiveOnboardingOptions.initialStep ?? 0;
+ const existingCompanyId = effectiveOnboardingOptions.companyId;
+
+ // Restore saved state from localStorage (read once on mount)
+ const saved = useMemo(loadSavedState, []);
+
+ const [step, setStep] = useState((saved?.step as Step) ?? initialStep);
+ const [onboardingPath, setOnboardingPath] = useState<"create" | "grow" | null>((saved?.onboardingPath as "create" | "grow" | null) ?? null);
+
+ // "Grow existing" questionnaire fields
+ const [growWorkflows, setGrowWorkflows] = useState((saved?.growWorkflows as string) ?? "");
+ const [growPainPoints, setGrowPainPoints] = useState((saved?.growPainPoints as string) ?? "");
+ const [growAutomate, setGrowAutomate] = useState((saved?.growAutomate as string) ?? "");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [modelOpen, setModelOpen] = useState(false);
+ const [modelSearch, setModelSearch] = useState("");
+
+ // Step 1
+ const [companyName, setCompanyName] = useState((saved?.companyName as string) ?? "");
+ const [companyGoal, setCompanyGoal] = useState((saved?.companyGoal as string) ?? "");
+ const [missionPath, setMissionPath] = useState<"direct" | "questionnaire" | null>((saved?.missionPath as "direct" | "questionnaire" | null) ?? null);
+ const [missionConfirmed, setMissionConfirmed] = useState((saved?.missionConfirmed as boolean) ?? false);
+ // Questionnaire answers
+ const [q1, setQ1] = useState((saved?.q1 as string) ?? ""); // What do you do?
+ const [q2, setQ2] = useState((saved?.q2 as string) ?? ""); // Who do you serve?
+ const [q3, setQ3] = useState((saved?.q3 as string) ?? ""); // Biggest bottleneck?
+ const [q4, setQ4] = useState((saved?.q4 as string) ?? ""); // What would success look like?
+
+ // Step 2
+ const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "Chief of staff");
+ const [adapterType, setAdapterType] = useState((saved?.adapterType as AdapterType) ?? "claude_local");
+ const [cwd, setCwd] = useState((saved?.cwd as string) ?? "");
+ const [model, setModel] = useState((saved?.model as string) ?? "");
+ const [command, setCommand] = useState((saved?.command as string) ?? "");
+ const [args, setArgs] = useState((saved?.args as string) ?? "");
+ const [url, setUrl] = useState((saved?.url as string) ?? "");
+ const [adapterEnvResult, setAdapterEnvResult] =
+ useState(null);
+ const [adapterEnvError, setAdapterEnvError] = useState(null);
+ const [adapterEnvLoading, setAdapterEnvLoading] = useState(false);
+ const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] =
+ useState(false);
+ const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
+ const [showMoreAdapters, setShowMoreAdapters] = useState(false);
+
+ // Created entity IDs — pre-populate from existing company when skipping step 1
+ const [createdCompanyId, setCreatedCompanyId] = useState(
+ existingCompanyId ?? (saved?.createdCompanyId as string) ?? null
+ );
+ const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState<
+ string | null
+ >((saved?.createdCompanyPrefix as string) ?? null);
+ const [createdAgentId, setCreatedAgentId] = useState((saved?.createdAgentId as string) ?? null);
+ const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState(
+ (saved?.createdCompanyGoalId as string) ?? null
+ );
+ const [createdProjectId, setCreatedProjectId] = useState(
+ (saved?.createdProjectId as string) ?? null
+ );
+ const [createdIssueRef, setCreatedIssueRef] = useState(
+ (saved?.createdIssueRef as string) ?? null
+ );
+
+ // Reset the route-dismissed flag when navigating to a different path.
+ useEffect(() => {
+ setRouteDismissed(false);
+ }, [location.pathname]);
+
+ // Sync step and company when onboarding opens with explicit options.
+ // Only override saved state when explicit options provide values.
+ useEffect(() => {
+ if (!effectiveOnboardingOpen) return;
+ // If explicit options are provided, they take precedence over saved state
+ if (effectiveOnboardingOptions.initialStep) {
+ setStep(effectiveOnboardingOptions.initialStep);
+ }
+ if (effectiveOnboardingOptions.companyId) {
+ setCreatedCompanyId(effectiveOnboardingOptions.companyId);
+ setCreatedCompanyPrefix(null);
+ }
+ }, [
+ effectiveOnboardingOpen,
+ effectiveOnboardingOptions.companyId,
+ effectiveOnboardingOptions.initialStep
+ ]);
+
+ // Backfill issue prefix for an existing company once companies are loaded.
+ useEffect(() => {
+ if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return;
+ const company = companies.find((c) => c.id === createdCompanyId);
+ if (company) setCreatedCompanyPrefix(company.issuePrefix);
+ }, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]);
+
+ // Persist wizard state to localStorage on every change
+ useEffect(() => {
+ if (!effectiveOnboardingOpen) return;
+ const state = {
+ step, companyName, companyGoal, missionPath, missionConfirmed,
+ q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
+ createdCompanyId, createdCompanyPrefix, createdAgentId,
+ createdCompanyGoalId, createdProjectId, createdIssueRef,
+ onboardingPath, growWorkflows, growPainPoints, growAutomate,
+ };
+ localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(state));
+ }, [
+ effectiveOnboardingOpen, step, companyName, companyGoal, missionPath, missionConfirmed,
+ q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
+ createdCompanyId, createdCompanyPrefix, createdAgentId,
+ createdCompanyGoalId, createdProjectId, createdIssueRef,
+ onboardingPath, growWorkflows, growPainPoints, growAutomate,
+ ]);
+
+ const {
+ data: adapterModels,
+ error: adapterModelsError,
+ isLoading: adapterModelsLoading,
+ isFetching: adapterModelsFetching
+ } = useQuery({
+ // The wizard doesn't expose an environment selector, so models always
+ // resolve against the local Paperclip host (environmentId = null).
+ queryKey: createdCompanyId
+ ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null)
+ : ["agents", "none", "adapter-models", adapterType, null],
+ queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }),
+ // Models are picked on step 4 (Connect a model).
+ enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4
+ });
+ const getCapabilities = useAdapterCapabilities();
+ const adapterCaps = getCapabilities(adapterType);
+ const isLocalAdapterCaps =
+ adapterCaps.supportsInstructionsBundle ||
+ adapterCaps.supportsSkills ||
+ adapterCaps.supportsLocalAgentJwt;
+ const isLocalAdapter =
+ isLocalAdapterCaps ||
+ adapterType === "claude_local" ||
+ adapterType === "codex_local" ||
+ adapterType === "gemini_local" ||
+ adapterType === "opencode_local" ||
+ adapterType === "pi_local" ||
+ adapterType === "cursor";
+ // Build adapter grids dynamically from the UI registry + display metadata.
+ // External/plugin adapters automatically appear with generic defaults, and
+ // server-disabled types are filtered out.
+ const { recommendedAdapters, moreAdapters } = useMemo(() => {
+ const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
+ const all = listUIAdapters()
+ .filter((a) =>
+ !SYSTEM_ADAPTER_TYPES.has(a.type) &&
+ !disabledTypes.has(a.type) &&
+ isVisualAdapterChoice(a.type)
+ )
+ .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
+
+ return {
+ recommendedAdapters: all.filter((a) => a.recommended),
+ moreAdapters: all.filter((a) => !a.recommended),
+ };
+ }, [disabledTypes]);
+
+ const COMMAND_PLACEHOLDERS: Record = {
+ claude_local: "claude",
+ codex_local: "codex",
+ gemini_local: "gemini",
+ pi_local: "pi",
+ cursor: "agent",
+ opencode_local: "opencode",
+ };
+ const effectiveAdapterCommand =
+ command.trim() ||
+ (COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, ""));
+
+ useEffect(() => {
+ if (step !== 4) return;
+ setAdapterEnvResult(null);
+ setAdapterEnvError(null);
+ }, [step, adapterType, model, command, args, url]);
+
+ const selectedModel = (adapterModels ?? []).find((m) => m.id === model);
+ const hasAnthropicApiKeyOverrideCheck =
+ adapterEnvResult?.checks.some(
+ (check) =>
+ check.code === "claude_anthropic_api_key_overrides_subscription"
+ ) ?? false;
+ const shouldSuggestUnsetAnthropicApiKey =
+ adapterType === "claude_local" &&
+ adapterEnvResult?.status === "fail" &&
+ hasAnthropicApiKeyOverrideCheck;
+ const filteredModels = useMemo(() => {
+ const query = modelSearch.trim().toLowerCase();
+ return (adapterModels ?? []).filter((entry) => {
+ if (!query) return true;
+ const provider = extractProviderIdWithFallback(entry.id, "");
+ return (
+ entry.id.toLowerCase().includes(query) ||
+ entry.label.toLowerCase().includes(query) ||
+ provider.toLowerCase().includes(query)
+ );
+ });
+ }, [adapterModels, modelSearch]);
+ const groupedModels = useMemo(() => {
+ if (adapterType !== "opencode_local") {
+ return [
+ {
+ provider: "models",
+ entries: [...filteredModels].sort((a, b) => a.id.localeCompare(b.id))
+ }
+ ];
+ }
+ const groups = new Map>();
+ for (const entry of filteredModels) {
+ const provider = extractProviderIdWithFallback(entry.id);
+ const bucket = groups.get(provider) ?? [];
+ bucket.push(entry);
+ groups.set(provider, bucket);
+ }
+ return Array.from(groups.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([provider, entries]) => ({
+ provider,
+ entries: [...entries].sort((a, b) => a.id.localeCompare(b.id))
+ }));
+ }, [filteredModels, adapterType]);
+
+ function reset() {
+ localStorage.removeItem(ONBOARDING_STORAGE_KEY);
+ setStep(0);
+ setOnboardingPath(null);
+ setGrowWorkflows("");
+ setGrowPainPoints("");
+ setGrowAutomate("");
+ setLoading(false);
+ setError(null);
+ setCompanyName("");
+ setCompanyGoal("");
+ setMissionPath(null);
+ setMissionConfirmed(false);
+ setQ1("");
+ setQ2("");
+ setQ3("");
+ setQ4("");
+ setAgentName("Chief of staff");
+ setAdapterType("claude_local");
+ setModel("");
+ setCommand("");
+ setArgs("");
+ setUrl("");
+ setAdapterEnvResult(null);
+ setAdapterEnvError(null);
+ setAdapterEnvLoading(false);
+ setForceUnsetAnthropicApiKey(false);
+ setUnsetAnthropicLoading(false);
+ setCreatedCompanyId(null);
+ setCreatedCompanyPrefix(null);
+ setCreatedAgentId(null);
+ setCreatedCompanyGoalId(null);
+ setCreatedProjectId(null);
+ setCreatedIssueRef(null);
+ }
+
+ function handleClose() {
+ reset();
+ closeOnboarding();
+ // On the /onboarding route the wizard is also kept open by the route
+ // itself, so closing the dialog must mark the route dismissed — otherwise
+ // effectiveOnboardingOpen stays true and the wizard re-renders instead of
+ // handing off to the launcher card (PAP-52).
+ setRouteDismissed(true);
+ }
+
+ async function handleLaunchToDashboard() {
+ if (!createdCompanyId || !createdAgentId) {
+ setError(INCOMPLETE_ONBOARDING_STATE_MESSAGE);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ let goalId = createdCompanyGoalId;
+ if (!goalId) {
+ const goals = await goalsApi.list(createdCompanyId);
+ goalId = selectDefaultCompanyGoalId(goals);
+ setCreatedCompanyGoalId(goalId);
+ }
+
+ let projectId = createdProjectId;
+ if (!projectId) {
+ const projects = await projectsApi.list(createdCompanyId);
+ const existingOnboardingProject = selectReusableOnboardingProject(projects);
+ if (existingOnboardingProject) {
+ projectId = existingOnboardingProject.id;
+ } else {
+ const project = await projectsApi.create(
+ createdCompanyId,
+ buildOnboardingProjectPayload(goalId)
+ );
+ projectId = project.id;
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.projects.list(createdCompanyId)
+ });
+ }
+ setCreatedProjectId(projectId);
+ }
+
+ if (!createdIssueRef) {
+ const issue = await issuesApi.create(
+ createdCompanyId,
+ buildOnboardingIssuePayload({
+ title: DEFAULT_TASK_TITLE,
+ description: DEFAULT_TASK_DESCRIPTION,
+ assigneeAgentId: createdAgentId,
+ projectId,
+ goalId
+ })
+ );
+ setCreatedIssueRef(issue.identifier ?? issue.id);
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.issues.list(createdCompanyId)
+ });
+ }
+
+ const prefix = createdCompanyPrefix;
+ setSelectedCompanyId(createdCompanyId);
+ reset();
+ closeOnboarding();
+ navigate(prefix ? `/${prefix}/dashboard` : "/dashboard");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to launch first task");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function buildAdapterConfig(): Record {
+ const adapter = getUIAdapter(adapterType);
+ const config = adapter.buildAdapterConfig({
+ ...defaultCreateValues,
+ adapterType,
+ model:
+ adapterType === "gemini_local"
+ ? model || DEFAULT_GEMINI_LOCAL_MODEL
+ : adapterType === "cursor"
+ ? model || DEFAULT_CURSOR_LOCAL_MODEL
+ : adapterType === "opencode_local"
+ ? model || DEFAULT_OPENCODE_LOCAL_MODEL
+ : model,
+ command,
+ args,
+ url,
+ dangerouslySkipPermissions:
+ adapterType === "claude_local" || adapterType === "opencode_local",
+ dangerouslyBypassSandbox:
+ adapterType === "codex_local"
+ ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
+ : defaultCreateValues.dangerouslyBypassSandbox
+ });
+ if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) {
+ const env =
+ typeof config.env === "object" &&
+ config.env !== null &&
+ !Array.isArray(config.env)
+ ? { ...(config.env as Record) }
+ : {};
+ env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
+ config.env = env;
+ }
+ return config;
+ }
+
+ async function runAdapterEnvironmentTest(
+ adapterConfigOverride?: Record
+ ): Promise {
+ if (!createdCompanyId) {
+ setAdapterEnvError(
+ "Create or select a company before testing adapter environment."
+ );
+ return null;
+ }
+ setAdapterEnvLoading(true);
+ setAdapterEnvError(null);
+ try {
+ const result = await agentsApi.testEnvironment(
+ createdCompanyId,
+ adapterType,
+ {
+ adapterConfig: adapterConfigOverride ?? buildAdapterConfig()
+ }
+ );
+ setAdapterEnvResult(result);
+ return result;
+ } catch (err) {
+ setAdapterEnvError(
+ err instanceof Error ? err.message : "Adapter environment test failed"
+ );
+ return null;
+ } finally {
+ setAdapterEnvLoading(false);
+ }
+ }
+
+ // Step 2 → 3 ("Confirm mission"): create the company + its company-level
+ // goal, then advance to naming the team lead. Guarded so revisiting the
+ // mission step (e.g. via Back) doesn't create a duplicate company.
+ async function handleConfirmMission() {
+ if (createdCompanyId) {
+ setStep(3);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const company = await companiesApi.create({ name: companyName.trim() });
+ setCreatedCompanyId(company.id);
+ setCreatedCompanyPrefix(company.issuePrefix);
+ setSelectedCompanyId(company.id);
+ queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
+
+ const parsedGoal = parseOnboardingGoalInput(companyGoal);
+ const goal = await goalsApi.create(company.id, {
+ title: parsedGoal.title,
+ ...(parsedGoal.description
+ ? { description: parsedGoal.description }
+ : {}),
+ level: "company",
+ status: "active"
+ });
+ setCreatedCompanyGoalId(goal.id);
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.goals.list(company.id)
+ });
+
+ setStep(3); // → Create your team lead
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create company");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ // Step 4 → 5 ("Give it a heartbeat"): hire the lead agent + seed its
+ // instructions, then advance to Review. Guarded so revisiting step 4
+ // doesn't hire a second agent.
+ async function handleGiveHeartbeat() {
+ if (!createdCompanyId) return;
+ if (createdAgentId) {
+ setStep(5);
+ return;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ if (adapterType === "opencode_local") {
+ const selectedModelId = model.trim();
+ if (!isValidOpenCodeModelId(selectedModelId)) {
+ setError(
+ "OpenCode requires an explicit model in provider/model format."
+ );
+ return;
+ }
+ if (adapterModelsError) {
+ setError(
+ adapterModelsError instanceof Error
+ ? adapterModelsError.message
+ : "Failed to load OpenCode models."
+ );
+ return;
+ }
+ if (adapterModelsLoading || adapterModelsFetching) {
+ setError(
+ "OpenCode models are still loading. Please wait and try again."
+ );
+ return;
+ }
+ const discoveredModels = adapterModels ?? [];
+ if (!discoveredModels.some((entry) => entry.id === selectedModelId)) {
+ setError(
+ discoveredModels.length === 0
+ ? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
+ : `Configured OpenCode model is unavailable: ${selectedModelId}`
+ );
+ return;
+ }
+ }
+
+ if (isLocalAdapter) {
+ const result = adapterEnvResult ?? (await runAdapterEnvironmentTest());
+ if (!result) return;
+ }
+
+ const hire = await agentsApi.hire(createdCompanyId, {
+ name: agentName.trim(),
+ role: "ceo",
+ adapterType,
+ adapterConfig: buildAdapterConfig(),
+ runtimeConfig: buildNewAgentRuntimeConfig()
+ });
+ if (hire.approval) {
+ await approvalsApi.approve(
+ hire.approval.id,
+ "Approved during onboarding first-agent setup."
+ );
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.approvals.list(createdCompanyId)
+ });
+ }
+ const agent = hire.agent;
+ setCreatedAgentId(agent.id);
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.agents.list(createdCompanyId)
+ });
+
+ // Seed the CEO's agent instructions file so the agent always has
+ // company context + a hiring-plan output format rule. Non-fatal on
+ // failure — the agent can still function with adapter defaults.
+ try {
+ const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId);
+ await agentsApi.saveInstructionsFile(
+ agent.id,
+ {
+ path: bundle.entryFile,
+ content: composeCeoInstructions({
+ companyName,
+ companyGoal,
+ growPath: onboardingPath === "grow",
+ growWorkflows,
+ growPainPoints,
+ growAutomate,
+ q1, q2, q3, q4,
+ }),
+ },
+ createdCompanyId,
+ );
+ } catch (err) {
+ console.warn("Failed to seed CEO instructions:", err);
+ }
+
+ // Advance to the Review step — the lead is now online. The user drives
+ // strategy + hiring from the planning chat after "Get started".
+ setStep(5);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create agent");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleUnsetAnthropicApiKey() {
+ if (!createdCompanyId || unsetAnthropicLoading) return;
+ setUnsetAnthropicLoading(true);
+ setError(null);
+ setAdapterEnvError(null);
+ setForceUnsetAnthropicApiKey(true);
+
+ const configWithUnset = (() => {
+ const config = buildAdapterConfig();
+ const env =
+ typeof config.env === "object" &&
+ config.env !== null &&
+ !Array.isArray(config.env)
+ ? { ...(config.env as Record) }
+ : {};
+ env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
+ config.env = env;
+ return config;
+ })();
+
+ try {
+ if (createdAgentId) {
+ await agentsApi.update(
+ createdAgentId,
+ { adapterConfig: configWithUnset },
+ createdCompanyId
+ );
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.agents.list(createdCompanyId)
+ });
+ }
+
+ const result = await runAdapterEnvironmentTest(configWithUnset);
+ if (result?.status === "fail") {
+ setError(
+ "Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing."
+ );
+ }
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Failed to unset ANTHROPIC_API_KEY and retry."
+ );
+ } finally {
+ setUnsetAnthropicLoading(false);
+ }
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ if (step === 0) return; // front door requires click
+ if (step === 1 && companyName.trim()) setStep(2);
+ else if (step === 2 && companyName.trim() && companyGoal.trim()) handleConfirmMission();
+ else if (step === 3 && agentName.trim()) setStep(4);
+ else if (step === 4 && agentName.trim()) handleGiveHeartbeat();
+ else if (step === 5) handleLaunchToDashboard();
+ }
+ }
+
+ if (!effectiveOnboardingOpen) return null;
+
+ const launchStateIncomplete = step === 5 && (!createdCompanyId || !createdAgentId);
+ const visibleError = error ?? (launchStateIncomplete ? INCOMPLETE_ONBOARDING_STATE_MESSAGE : null);
+
+ return (
+ {
+ if (!open) {
+ handleClose();
+ }
+ }}
+ >
+
+ {/* Plain div instead of DialogOverlay — Radix's overlay wraps in
+ RemoveScroll which blocks wheel events on our custom (non-DialogContent)
+ scroll container. A plain div preserves the background without scroll-locking. */}
+
+
+ {/* Close button */}
+
+
+ Close
+
+
+ {/* Step 0: Front Door — full-screen choice */}
+ {step === 0 && (
+
+ {
+ setOnboardingPath(path);
+ setStep(1);
+ }} />
+
+ )}
+
+ {/* Left half — form (steps 1+) */}
+ {step !== 0 && (
+
+
+ {/* 5-segment progress bar (brand .wsteps/.wstep) — segment N
+ filled once step ≥ N. Completed segments jump back. */}
+
+ {([1, 2, 3, 4, 5] as const).map((s) => {
+ const filled = step >= s;
+ const canJump = s < step;
+ return (
+ canJump && setStep(s as Step)}
+ className={cn(
+ "h-1 flex-1 rounded-full transition-colors",
+ filled ? "bg-foreground" : "bg-muted",
+ canJump ? "cursor-pointer" : "cursor-default"
+ )}
+ />
+ );
+ })}
+
+
+ {/* Persistent evolving capsule (steps 3–5): a single AgentCapsule
+ held in the same tree slot so React reuses the DOM node and the
+ morph reads as one capsule coming to life — dashed slot →
+ solid (configured) → liquid fill + blue glow (online). */}
+ {step >= 3 && step <= 5 && (
+
+
+
+ {step === 5 ? (
+
+ ) : (
+
+ )}
+
+
+
+ {step === 3
+ ? "Create your team lead"
+ : step === 4
+ ? "Connect a model"
+ : "Review"}
+
+
+ {step === 3 ? (
+ <>
+ Name your lead. They'll help drive{" "}
+ {companyName} {" "}
+ toward its mission. We default to{" "}
+ Chief of staff —
+ rename it to anything you like.
+ >
+ ) : step === 4 ? (
+ <>Pick the adapter and model your lead will run on, then check the environment.>
+ ) : (
+ <>Everything's set up — your team lead is online and ready to work.>
+ )}
+
+
+
+
+
+
+
+ {step === 3 ? (
+ "an empty slot for an agent"
+ ) : step === 4 ? (
+ "your team lead, taking shape"
+ ) : (
+ <>
+ {agentName} {" "}
+ is online and ready to work!
+ >
+ )}
+
+
+
+ )}
+
+ {/* Step content */}
+ {step === 2 && onboardingPath === "grow" && (
+
+
+
+
+
+
+
Tell us about your team
+
+ We'll use this to set up your lead agent and plan which agents to add.
+
+
+
+
+ What does your team work on?
+ setQ1(e.target.value)}
+ />
+
+
+ What are your current workflows?
+
+
+ What pain points would you solve with AI?
+
+
+ What would you automate first?
+ setGrowAutomate(e.target.value)}
+ />
+
+ {companyName.trim() && q1.trim() && (
+ <>
+ {!companyGoal.trim() && (
+
{
+ const parts = [q1.trim()];
+ if (growPainPoints.trim()) parts.push(`Key challenge: ${growPainPoints.trim()}`);
+ if (growAutomate.trim()) parts.push(`First priority: automate ${growAutomate.trim().toLowerCase()}`);
+ setCompanyGoal(parts.join(". "));
+ }}
+ >
+ Generate mission from answers
+
+ )}
+ {companyGoal.trim() && (
+
+ Generated mission — edit however you like:
+
+ )}
+ >
+ )}
+
{ setOnboardingPath(null); setStep(0); }}
+ >
+ ← Back to start
+
+
+ )}
+
+ {/* Step 1: Name your company (both paths) */}
+ {step === 1 && (
+
+
+
+
+
+
+
Name your company
+
+ What should we call your company?
+
+
+
+
+
+ Company name
+
+ setCompanyName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && companyName.trim()) {
+ e.preventDefault();
+ if (onboardingPath !== "grow" && !missionPath) setMissionPath("direct");
+ setStep(2);
+ }
+ }}
+ autoFocus
+ />
+
+
{ setOnboardingPath(null); setStep(0); }}
+ >
+ ← Back to start
+
+
+ )}
+
+ {/* Step 2: Define your mission */}
+ {step === 2 && onboardingPath !== "grow" && (
+
+
+
+
+
+
+
Define your mission
+
+ Your mission guides everything — your lead agent, who you bring on, and the work {companyName} takes on.
+
+
+
+
+ {/* Mission path selector */}
+
+
+ How would you like to define your mission?
+
+
+ setMissionPath("direct")}
+ >
+
+ I know my mission
+
+ Type it directly
+
+
+ setMissionPath("questionnaire")}
+ >
+
+ Help me figure it out
+
+ Answer a few questions
+
+
+
+
+
+ {/* Direct mission input */}
+ {missionPath === "direct" && (
+
+
+
+ Mission
+
+
+ {/* Prompt chips for inspiration */}
+
+ {MISSION_PROMPT_CHIPS.map((chip) => (
+ setCompanyGoal(chip)}
+ >
+ {chip}
+
+ ))}
+
+
+ )}
+
+ {/* Questionnaire path */}
+ {missionPath === "questionnaire" && !missionConfirmed && (
+
+ )}
+
+ {/* Questionnaire result — editable mission */}
+ {missionPath === "questionnaire" && missionConfirmed && (
+
+
+
+ Here's your draft mission — edit it however you like:
+
+
+
{ setMissionConfirmed(false); setCompanyGoal(""); }}
+ >
+ ← Back to questions
+
+
+ )}
+
+ {/* Confirm mission note */}
+ {companyGoal.trim() && (
+
+ You can always change your mission later in settings.
+
+ )}
+
+
setStep(1)}
+ >
+ ← Change company name
+
+
+ )}
+
+ {/* Step 3: Create your team lead — name only (capsule above) */}
+ {step === 3 && (
+
+
+
+ Name
+
+ setAgentName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && agentName.trim()) {
+ e.preventDefault();
+ setStep(4);
+ }
+ }}
+ autoFocus
+ />
+
+
+ )}
+
+ {/* Step 4: Connect a model — adapter + model + env check (capsule above) */}
+ {step === 4 && (
+
+ {/* Adapter type radio cards */}
+
+
+ Adapter type
+
+
+ {recommendedAdapters.map((opt) => (
+ {
+ const nextType = opt.type;
+ setAdapterType(nextType);
+ if (nextType === "codex_local") {
+ return;
+ }
+ if (nextType === "opencode_local") {
+ setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
+ return;
+ }
+ setModel("");
+ }}
+ >
+ {opt.recommended && (
+
+ Recommended
+
+ )}
+
+ {opt.label}
+
+ {opt.description}
+
+
+ ))}
+
+
+
setShowMoreAdapters((v) => !v)}
+ >
+
+ More Agent Adapter Types
+
+
+ {showMoreAdapters && (
+
+ {moreAdapters.map((opt) => (
+ {
+ if (opt.comingSoon) return;
+ const nextType = opt.type;
+ setAdapterType(nextType);
+ if (nextType === "gemini_local" && !model) {
+ setModel(DEFAULT_GEMINI_LOCAL_MODEL);
+ return;
+ }
+ if (nextType === "cursor" && !model) {
+ setModel(DEFAULT_CURSOR_LOCAL_MODEL);
+ return;
+ }
+ if (nextType === "opencode_local") {
+ setModel(DEFAULT_OPENCODE_LOCAL_MODEL);
+ return;
+ }
+ setModel("");
+ }}
+ >
+
+ {opt.label}
+
+ {opt.comingSoon
+ ? opt.disabledLabel ?? "Coming soon"
+ : opt.description}
+
+
+ ))}
+
+ )}
+
+
+ {/* Conditional adapter fields */}
+ {isLocalAdapter && (
+
+
+
+ Model
+
+
{
+ setModelOpen(next);
+ if (!next) setModelSearch("");
+ }}
+ >
+
+
+
+ {selectedModel
+ ? selectedModel.label
+ : model ||
+ (adapterType === "opencode_local"
+ ? "Select model (required)"
+ : "Default")}
+
+
+
+
+
+ setModelSearch(e.target.value)}
+ autoFocus
+ />
+ {adapterType !== "opencode_local" && (
+ {
+ setModel("");
+ setModelOpen(false);
+ }}
+ >
+ Default
+
+ )}
+
+ {groupedModels.map((group) => (
+
+ {adapterType === "opencode_local" && (
+
+ {group.provider} ({group.entries.length})
+
+ )}
+ {group.entries.map((m) => (
+
{
+ setModel(m.id);
+ setModelOpen(false);
+ }}
+ >
+
+ {adapterType === "opencode_local"
+ ? extractModelName(m.id)
+ : m.label}
+
+
+ ))}
+
+ ))}
+
+ {filteredModels.length === 0 && (
+
+ No models discovered.
+
+ )}
+
+
+
+
+ )}
+
+ {isLocalAdapter && (
+
+
+
+
+ Adapter environment check
+
+
+ Runs a live probe that asks the adapter CLI to
+ respond with hello.
+
+
+
void runAdapterEnvironmentTest()}
+ >
+ {adapterEnvLoading ? "Testing..." : "Test now"}
+
+
+
+ {adapterEnvError && (
+
+ {adapterEnvError}
+
+ )}
+
+ {adapterEnvResult &&
+ adapterEnvResult.status === "pass" ? (
+
+
+ Passed
+
+ ) : adapterEnvResult ? (
+
+ ) : null}
+
+ {shouldSuggestUnsetAnthropicApiKey && (
+
+
+ Claude failed while{" "}
+ ANTHROPIC_API_KEY {" "}
+ is set. You can clear it in this adapter config
+ and retry the probe.
+
+
void handleUnsetAnthropicApiKey()}
+ >
+ {unsetAnthropicLoading
+ ? "Retrying..."
+ : "Unset ANTHROPIC_API_KEY"}
+
+
+ )}
+
+ {adapterEnvResult && adapterEnvResult.status === "fail" && (
+
+
Manual debug
+
+ {adapterType === "cursor"
+ ? `${effectiveAdapterCommand} -p --mode ask --output-format json \"Respond with hello.\"`
+ : adapterType === "codex_local"
+ ? `${effectiveAdapterCommand} exec --json -`
+ : adapterType === "gemini_local"
+ ? `${effectiveAdapterCommand} --output-format json "Respond with hello."`
+ : adapterType === "opencode_local"
+ ? `${effectiveAdapterCommand} run --format json "Respond with hello."`
+ : `${effectiveAdapterCommand} --print - --output-format stream-json --verbose`}
+
+
+ Prompt:{" "}
+ Respond with hello.
+
+ {adapterType === "cursor" ||
+ adapterType === "codex_local" ||
+ adapterType === "gemini_local" ||
+ adapterType === "opencode_local" ? (
+
+ If auth fails, set{" "}
+
+ {adapterType === "cursor"
+ ? "CURSOR_API_KEY"
+ : adapterType === "gemini_local"
+ ? "GEMINI_API_KEY"
+ : "OPENAI_API_KEY"}
+ {" "}
+ in env or run{" "}
+
+ {adapterType === "cursor"
+ ? "agent login"
+ : adapterType === "codex_local"
+ ? "codex login"
+ : adapterType === "gemini_local"
+ ? "gemini auth"
+ : "opencode auth login"}
+
+ .
+
+ ) : (
+
+ If login is required, run{" "}
+ claude login {" "}
+ and retry.
+
+ )}
+
+ )}
+
+ )}
+
+ {(adapterType === "http" ||
+ adapterType === "openclaw_gateway") && (
+
+
+ {adapterType === "openclaw_gateway"
+ ? "Gateway URL"
+ : "Webhook URL"}
+
+ setUrl(e.target.value)}
+ />
+
+ )}
+
+ )}
+
+ {/* Step 5: Review — lead is online (shared capsule above) */}
+ {step === 5 && (
+
+ {/* Review checklist — everything that's now set up */}
+
+ {[
+ { label: "Company name", done: Boolean(companyName.trim()) },
+ { label: "Mission", done: Boolean(companyGoal.trim()) },
+ { label: "Agent created", done: Boolean(createdAgentId) },
+ { label: "Model connected", done: Boolean(createdAgentId) },
+ ].map(({ label, done }) => (
+
+
+
+
+
+ {label}
+
+
+ ))}
+
+
+ {companyGoal.trim() && (
+
+ "{companyGoal}"
+
+ )}
+
+ We'll create the first task for {agentName} and take you to the dashboard.
+
+
+ )}
+
+ {/* Error */}
+ {visibleError && (
+
+ )}
+
+ {/* Footer navigation */}
+
+
+ {step > 1 && step > (effectiveOnboardingOptions.initialStep ?? 0) && (
+
setStep((step - 1) as Step)}
+ disabled={loading}
+ >
+
+ Back
+
+ )}
+
+
+ {step === 1 && (
+
{
+ if (onboardingPath !== "grow" && !missionPath) setMissionPath("direct");
+ setStep(2);
+ }}
+ >
+ Next
+
+
+ )}
+ {step === 2 && (
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ {loading ? "Creating..." : "Confirm mission"}
+
+ )}
+ {step === 3 && (
+
setStep(4)}
+ >
+ Next
+
+
+ )}
+ {step === 4 && (
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ {loading ? "Bringing to life..." : "Give it a heartbeat"}
+
+ )}
+ {step === 5 && (
+
+ {loading ? (
+
+ ) : (
+
+ )}
+ {loading ? "Launching..." : "Get started"}
+
+ )}
+
+
+
+
+ )}
+
+ {/* Right half — ASCII art (hidden on mobile, only for the team
+ name + mission steps) */}
+
+
+
+
+ );
+}
+
+function AdapterEnvironmentResult({
+ result
+}: {
+ result: AdapterEnvironmentTestResult;
+}) {
+ const statusLabel =
+ result.status === "pass"
+ ? "Passed"
+ : result.status === "warn"
+ ? "Warnings"
+ : "Failed";
+ const statusClass =
+ result.status === "pass"
+ ? "text-green-700 dark:text-green-300 border-green-300 dark:border-green-500/40 bg-green-50 dark:bg-green-500/10"
+ : result.status === "warn"
+ ? "text-amber-700 dark:text-amber-300 border-amber-300 dark:border-amber-500/40 bg-amber-50 dark:bg-amber-500/10"
+ : "text-red-700 dark:text-red-300 border-red-300 dark:border-red-500/40 bg-red-50 dark:bg-red-500/10";
+
+ return (
+
+
+ {statusLabel}
+
+ {new Date(result.testedAt).toLocaleTimeString()}
+
+
+
+ {result.checks.map((check, idx) => (
+
+
+ {check.level}
+
+ ·
+ {check.message}
+ {check.detail && (
+
+ ({check.detail})
+
+ )}
+ {check.hint && (
+
+ Hint: {check.hint}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/ui/src/components/OnboardingWizardVariant.test.tsx b/ui/src/components/OnboardingWizardVariant.test.tsx
index b52aeda4a20..0381e60698e 100644
--- a/ui/src/components/OnboardingWizardVariant.test.tsx
+++ b/ui/src/components/OnboardingWizardVariant.test.tsx
@@ -3,51 +3,25 @@
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-// The shell reads dialog + company state and renders CloudOnboardingFlow. Mock those
-// so the test exercises the shell's gating + option→prop mapping in isolation.
-const dialogState = vi.hoisted(() => ({ value: null as unknown }));
-vi.mock("@/context/DialogContext", () => ({
- useDialog: () => dialogState.value,
-}));
-vi.mock("@/context/CompanyContext", () => ({
- useCompany: () => ({
- companies: [{ id: "c1", issuePrefix: "ACME", name: "Acme" }],
- loading: false,
- }),
-}));
-vi.mock("@/lib/router", () => ({
- useLocation: () => ({ pathname: "/dashboard" }),
- useParams: () => ({}),
-}));
-vi.mock("./onboarding/CloudOnboardingFlow", () => ({
- CloudOnboardingFlow: (props: { initialStep?: string; existingCompany?: { id: string } }) => (
-
- ),
-}));
-
import { OnboardingWizardVariant } from "./OnboardingWizardVariant";
-function baseDialog(overrides: Record = {}) {
- return {
- onboardingOpen: false,
- onboardingOptions: {},
- closeOnboarding: vi.fn(),
- onboardingRouteDismissed: true,
- setOnboardingRouteDismissed: vi.fn(),
- ...overrides,
- };
-}
+const mockInstanceSettingsApi = vi.hoisted(() => ({
+ getExperimental: vi.fn(),
+}));
-describe("OnboardingWizardVariant", () => {
+vi.mock("@/api/instanceSettings", () => ({
+ instanceSettingsApi: mockInstanceSettingsApi,
+}));
+
+vi.mock("./OnboardingWizard", () => ({
+ OnboardingWizard: () =>
,
+}));
+
+describe("OnboardingWizardVariant (PAP-138)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
- function render() {
+ function renderVariant() {
root = createRoot(container);
flushSync(() => {
root!.render( );
@@ -68,29 +42,11 @@ describe("OnboardingWizardVariant", () => {
vi.clearAllMocks();
});
- it("renders nothing when onboarding is closed", () => {
- dialogState.value = baseDialog();
- render();
- expect(container.querySelector('[data-testid="onboarding-flow"]')).toBeNull();
- });
+ it("renders the capsule wizard without reading the chat flag", () => {
+ mockInstanceSettingsApi.getExperimental.mockResolvedValue({});
+ renderVariant();
- it("renders the flow at the start step for a fresh open", () => {
- dialogState.value = baseDialog({ onboardingOpen: true, onboardingOptions: {} });
- render();
- const el = container.querySelector('[data-testid="onboarding-flow"]');
- expect(el).not.toBeNull();
- expect(el?.getAttribute("data-initial-step")).toBe("start");
- expect(el?.getAttribute("data-company")).toBe("");
- });
-
- it("starts at the agent step with the company preset when adding to an existing company", () => {
- dialogState.value = baseDialog({
- onboardingOpen: true,
- onboardingOptions: { initialStep: 2, companyId: "c1" },
- });
- render();
- const el = container.querySelector('[data-testid="onboarding-flow"]');
- expect(el?.getAttribute("data-initial-step")).toBe("agent");
- expect(el?.getAttribute("data-company")).toBe("c1");
+ expect(container.querySelector('[data-testid="wizard-capsule"]')).not.toBeNull();
+ expect(mockInstanceSettingsApi.getExperimental).not.toHaveBeenCalled();
});
});
diff --git a/ui/src/components/OnboardingWizardVariant.tsx b/ui/src/components/OnboardingWizardVariant.tsx
index 1254edb84ed..a6a6ee3cc0a 100644
--- a/ui/src/components/OnboardingWizardVariant.tsx
+++ b/ui/src/components/OnboardingWizardVariant.tsx
@@ -1,72 +1,10 @@
-import { useEffect } from "react";
-import { useLocation, useParams } from "@/lib/router";
-import { useDialog } from "@/context/DialogContext";
-import { useCompany } from "@/context/CompanyContext";
-import { resolveRouteOnboardingOptions } from "@/lib/onboarding-route";
-import { CloudOnboardingFlow } from "./onboarding/CloudOnboardingFlow";
+import { OnboardingWizard } from "./OnboardingWizard";
/**
- * App-integration shell for onboarding. Owns the open/close + route logic and
- * renders the CloudOnboardingFlow (the real app targets the cloud flow; the
- * local flow is harness-only for now). Onboarding opens either explicitly via
- * the dialog context (openOnboarding) or automatically on the /onboarding route
- * (and its /:companyPrefix/onboarding "add an agent" variant).
+ * Default onboarding wizard. Conference-room chat is now the only surface left
+ * behind `enableConferenceRoomChat`; onboarding stays available without that
+ * experimental flag.
*/
export function OnboardingWizardVariant() {
- const {
- onboardingOpen,
- onboardingOptions,
- closeOnboarding,
- onboardingRouteDismissed,
- setOnboardingRouteDismissed,
- } = useDialog();
- const { companies, loading: companiesLoading } = useCompany();
- const location = useLocation();
- const { companyPrefix } = useParams<{ companyPrefix?: string }>();
-
- // Reset the route-dismissed flag when navigating to a different path, so the
- // route can re-open onboarding after the user has dismissed it elsewhere.
- useEffect(() => {
- setOnboardingRouteDismissed(false);
- }, [location.pathname]);
-
- // Support opening from the /onboarding route in addition to the dialog. Wait
- // for companies to load before resolving a company-scoped route so we don't
- // momentarily treat an existing-company entry as a fresh one.
- const routeOptions =
- companyPrefix && companiesLoading
- ? null
- : resolveRouteOnboardingOptions({
- pathname: location.pathname,
- companyPrefix,
- companies,
- });
-
- const open = onboardingOpen || (routeOptions !== null && !onboardingRouteDismissed);
- if (!open) return null;
-
- const options = onboardingOpen ? onboardingOptions : routeOptions ?? {};
- const existingCompany = options.companyId
- ? companies.find((company) => company.id === options.companyId)
- : undefined;
-
- function handleClose() {
- closeOnboarding();
- // On the /onboarding route the shell is also kept open by the route itself,
- // so closing must mark the route dismissed — otherwise `open` stays true and
- // the flow re-renders instead of handing back to the launcher card (PAP-52).
- setOnboardingRouteDismissed(true);
- }
-
- return (
-
- );
+ return ;
}
diff --git a/ui/src/components/onboarding/AgentPreview.tsx b/ui/src/components/onboarding/AgentPreview.tsx
deleted file mode 100644
index 0ca3a1c38a1..00000000000
--- a/ui/src/components/onboarding/AgentPreview.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { motion } from "motion/react";
-import { PREVIEW_REVEAL_DURATION, STEP_EASE } from "./onboarding-motion";
-
-/**
- * Name/role preview under the capsule (agent + task steps). Collapsed (zero
- * height) until an identity exists, so the step opens compact; on selection the
- * block grows to full height — the viewport-centered card re-centers each
- * frame, so the capsule slides smoothly up into place — while the labels fade
- * in without moving. Slide and fade share one duration; the fade starts after
- * 25% of it. Both lines keep fixed heights once visible so typing a name
- * afterwards never shifts the layout again.
- */
-export function AgentPreview({
- agentName,
- agentRole,
-}: {
- agentName: string;
- agentRole: string;
-}) {
- const previewVisible = Boolean(agentName || agentRole);
- return (
-
-
-
- {agentName || " "}
-
-
- {agentRole || " "}
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/CloudOnboardingFlow.tsx b/ui/src/components/onboarding/CloudOnboardingFlow.tsx
deleted file mode 100644
index 0bf5d3ac207..00000000000
--- a/ui/src/components/onboarding/CloudOnboardingFlow.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-// Cloud onboarding flow — the thin container that owns state + backend wiring
-// and composes the shared step views inside the shared OnboardingScaffold.
-// Advances Start → Company → Agent → Task, then launches the first task and
-// opens the dashboard. The local flow (LocalOnboardingFlow) reuses the same
-// shared steps + shell and inserts its extra steps.
-import { useState } from "react";
-import { useNavigate } from "@/lib/router";
-import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
-import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
-import { OnboardingScaffold } from "./OnboardingScaffold";
-import { StartStep } from "./steps/StartStep";
-import { CompanyStep } from "./steps/CompanyStep";
-import { AgentStep } from "./steps/AgentStep";
-import { TaskStep } from "./steps/TaskStep";
-
-type Step = "start" | "company" | "agent" | "task";
-
-const DEFAULT_ADAPTER = "claude_local";
-
-// Numbered steps drive the Stepper position ("Step N of M").
-const NUMBERED: Step[] = ["company", "agent", "task"];
-function stepper(s: Step) {
- return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
-}
-
-export interface CloudOnboardingFlowProps {
- /** Called when the user dismisses onboarding. */
- onClose?: () => void;
- /** Starting step. Defaults to "start". Used by the standalone preview harness. */
- initialStep?: Step;
- /**
- * Preview-only: skip all backend calls (company/agent/task creation) so the
- * flow can be clicked end-to-end without a backend. Used by the standalone
- * preview harness — never enabled in the real app.
- */
- previewMock?: boolean;
- /**
- * When set, onboarding runs against an already-created company (the "add an
- * agent to an existing company" entry): company creation is skipped and the
- * flow starts at the agent step.
- */
- existingCompany?: { id: string; prefix: string | null };
-}
-
-export function CloudOnboardingFlow({
- onClose,
- initialStep = "start",
- previewMock = false,
- existingCompany,
-}: CloudOnboardingFlowProps) {
- const navigate = useNavigate();
- const flow = useOnboardingFlow(
- existingCompany
- ? { createdCompanyId: existingCompany.id, createdCompanyPrefix: existingCompany.prefix }
- : undefined,
- );
-
- const [step, setStep] = useState(initialStep);
- const [companyName, setCompanyName] = useState("");
- const [mission, setMission] = useState("");
- const [agentRole, setAgentRole] = useState("");
- const [agentName, setAgentName] = useState("");
- const [taskChoice, setTaskChoice] = useState(null);
- const [customTask, setCustomTask] = useState("");
-
- function handleRoleChange(value: string) {
- setAgentRole(value);
- setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
- }
-
- async function handleCreateCompany() {
- if (previewMock) {
- setStep("agent");
- return;
- }
- const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
- if (result) setStep("agent");
- }
-
- async function handleCreateAgent() {
- if (previewMock) {
- setStep("task");
- return;
- }
- const result = await flow.hireLeadAgent({
- agentName: agentName || agentRole,
- adapter: { adapterType: DEFAULT_ADAPTER, model: "", command: "", args: "", url: "" },
- instructions: {
- companyName,
- companyGoal: mission,
- growPath: false,
- growWorkflows: "",
- growPainPoints: "",
- growAutomate: "",
- q1: "",
- q2: "",
- q3: "",
- q4: "",
- },
- // The cloud agent step has no environment probe; keep hire simple.
- requireEnvProbe: false,
- });
- if (result) setStep("task");
- }
-
- async function handleGetStarted() {
- if (!taskChoice) return;
- if (previewMock) {
- window.alert(
- "Preview complete — in the real app this launches the first task and opens your dashboard.",
- );
- return;
- }
- const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
- if (result) {
- onClose?.();
- navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
- }
- }
-
- return (
-
- {step === "start" && setStep("company")} />}
- {step === "company" && (
- setStep("start")}
- onNext={handleCreateCompany}
- loading={flow.loading}
- />
- )}
- {step === "agent" && (
- onClose?.() : () => setStep("company")}
- onNext={handleCreateAgent}
- loading={flow.loading}
- />
- )}
- {step === "task" && (
- setStep("agent")}
- onGetStarted={handleGetStarted}
- loading={flow.loading}
- error={flow.error}
- />
- )}
-
- );
-}
diff --git a/ui/src/components/onboarding/FooterNav.tsx b/ui/src/components/onboarding/FooterNav.tsx
deleted file mode 100644
index 1f5bec15e68..00000000000
--- a/ui/src/components/onboarding/FooterNav.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
-import { Button } from "@/components/ui/button";
-
-/**
- * Shared footer navigation for onboarding step cards: a ghost pill "Back"
- * button (visible on hover) and a primary pill CTA that shows a spinner +
- * loading label while its action runs.
- */
-export function FooterNav({
- onBack,
- primaryLabel,
- primaryDisabled,
- loading,
- loadingLabel,
- onPrimary,
-}: {
- onBack: () => void;
- primaryLabel: string;
- primaryDisabled?: boolean;
- loading?: boolean;
- loadingLabel?: string;
- onPrimary: () => void;
-}) {
- return (
-
- {/* has-[>svg]:pr-4 gives the "Back" text room from the pill's right edge
- (overrides size="sm"'s has-[>svg]:px-2.5 for the right side only). */}
-
-
- Back
-
-
- {loading ? : null}
- {loading && loadingLabel ? loadingLabel : primaryLabel}
- {!loading ? : null}
-
-
- );
-}
diff --git a/ui/src/components/onboarding/GithubStarInterstitial.tsx b/ui/src/components/onboarding/GithubStarInterstitial.tsx
deleted file mode 100644
index 526cc682ac0..00000000000
--- a/ui/src/components/onboarding/GithubStarInterstitial.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { Github, Star } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
-
-const PAPERCLIP_REPO_URL = "https://github.com/paperclipai/paperclip";
-
-/**
- * Local-flow only: a warm "star us on GitHub" interstitial shown after "Get
- * started", before landing in the app. "Star on GitHub" opens the repo in a new
- * tab and continues; "Not right now" just continues. Both call onContinue,
- * which runs the same completion as the cloud flow (launch task → dashboard).
- */
-export function GithubStarInterstitial({ onContinue }: { onContinue: () => void }) {
- return (
-
-
-
-
-
-
-
- {
- window.open(PAPERCLIP_REPO_URL, "_blank", "noopener,noreferrer");
- onContinue();
- }}
- >
-
- Star on GitHub
-
-
- Not right now
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/LocalOnboardingFlow.tsx b/ui/src/components/onboarding/LocalOnboardingFlow.tsx
deleted file mode 100644
index da9d3666a13..00000000000
--- a/ui/src/components/onboarding/LocalOnboardingFlow.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-// Local onboarding flow — the thin container for the local (self-hosted) app.
-// Reuses the shared step views + shell (OnboardingScaffold) and inserts the
-// local-only pieces: an optional email ask before the numbered steps, a
-// model/adapter step after naming the agent (which runs the env probe on hire),
-// and a "star us on GitHub" interstitial after "Get started".
-//
-// Order: Start → Email → Company(1) → Agent(2) → Adapter(3) → Task(4)
-// → [Get started] → GitHub-star interstitial → dashboard.
-import { useEffect, useRef, useState } from "react";
-import { useNavigate } from "@/lib/router";
-import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
-import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
-import { OnboardingScaffold } from "./OnboardingScaffold";
-import { AUTH_EXIT_DURATION, OnboardingAuthBackdrop } from "./OnboardingAuthBackdrop";
-import { GithubStarInterstitial } from "./GithubStarInterstitial";
-import { StartStep } from "./steps/StartStep";
-import { EmailStep } from "./steps/EmailStep";
-import { CompanyStep } from "./steps/CompanyStep";
-import { AgentStep } from "./steps/AgentStep";
-import { AdapterStep } from "./steps/AdapterStep";
-import { TaskStep } from "./steps/TaskStep";
-
-type Step = "start" | "email" | "company" | "agent" | "adapter" | "task";
-
-const DEFAULT_ADAPTER = "claude_local";
-
-// Numbered steps drive the Stepper position ("Step N of M").
-const NUMBERED: Step[] = ["company", "agent", "adapter", "task"];
-function stepper(s: Step) {
- return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
-}
-
-export interface LocalOnboardingFlowProps {
- onClose?: () => void;
- initialStep?: Step;
- previewMock?: boolean;
-}
-
-export function LocalOnboardingFlow({
- onClose,
- initialStep = "start",
- previewMock = false,
-}: LocalOnboardingFlowProps) {
- const navigate = useNavigate();
- const flow = useOnboardingFlow();
-
- const [step, setStep] = useState(initialStep);
- const [showGithubStar, setShowGithubStar] = useState(false);
- // The welcome screen is rendered by OnboardingAuthBackdrop — the same shell
- // the cloud flow's auth screens use — so leaving it plays the identical
- // hand-off: card lowers + fades while the paperclip fades out, and only once
- // that finishes does the step shell mount. "exiting" holds that gap.
- const [welcomePhase, setWelcomePhase] = useState<"welcome" | "exiting" | "done">(
- initialStep === "start" ? "welcome" : "done",
- );
- const welcomeTimer = useRef(undefined);
-
- useEffect(() => () => window.clearTimeout(welcomeTimer.current), []);
-
- function leaveWelcome() {
- setWelcomePhase("exiting");
- welcomeTimer.current = window.setTimeout(() => {
- setStep("email");
- setWelcomePhase("done");
- }, AUTH_EXIT_DURATION * 1000);
- }
-
- const [email, setEmail] = useState("");
- const [companyName, setCompanyName] = useState("");
- const [mission, setMission] = useState("");
- const [agentRole, setAgentRole] = useState("");
- const [agentName, setAgentName] = useState("");
- const [adapterType, setAdapterType] = useState(DEFAULT_ADAPTER);
- const [taskChoice, setTaskChoice] = useState(null);
- const [customTask, setCustomTask] = useState("");
-
- const adapterInput = { adapterType, model: "", command: "", args: "", url: "" };
-
- // Switching adapters invalidates the previous adapter's environment probe:
- // drop it so the retry re-probes and the step stops showing a verdict about
- // an adapter the user is no longer hiring on.
- function handleAdapterChange(value: string) {
- setAdapterType(value);
- flow.clearAdapterEnvResult();
- }
-
- function handleRoleChange(value: string) {
- setAgentRole(value);
- setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
- }
-
- async function handleCreateCompany() {
- if (previewMock) {
- setStep("agent");
- return;
- }
- const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
- if (result) setStep("agent");
- }
-
- // Adapter step → hire the lead agent on the chosen local adapter, requiring
- // the environment probe to pass (the local flow's key difference from cloud).
- async function handleHireAgent() {
- if (previewMock) {
- setStep("task");
- return;
- }
- const result = await flow.hireLeadAgent({
- agentName: agentName || agentRole,
- adapter: adapterInput,
- instructions: {
- companyName,
- companyGoal: mission,
- growPath: false,
- growWorkflows: "",
- growPainPoints: "",
- growAutomate: "",
- q1: "",
- q2: "",
- q3: "",
- q4: "",
- },
- requireEnvProbe: true,
- });
- if (result) setStep("task");
- }
-
- function handleGetStarted() {
- if (!taskChoice) return;
- setShowGithubStar(true);
- }
-
- // Runs from the interstitial's CTAs — the same completion as the cloud flow.
- async function completeOnboarding() {
- if (!taskChoice) return;
- if (previewMock) {
- window.alert(
- "Preview complete — in the real app this launches the first task and opens your dashboard.",
- );
- return;
- }
- const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
- if (result) {
- onClose?.();
- navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
- }
- }
-
- // Welcome screen: same shell + exit transition as the cloud auth screens.
- // The local flow skips sign-in, so this screen carries the orbiting paperclip.
- if (welcomePhase !== "done") {
- return (
-
-
-
- );
- }
-
- return (
-
- {showGithubStar ? (
-
- ) : (
- <>
- {step === "email" && (
- setStep("company")}
- onSkip={() => setStep("company")}
- />
- )}
- {step === "company" && (
- setStep("email")}
- onNext={handleCreateCompany}
- loading={flow.loading}
- />
- )}
- {step === "agent" && (
- setStep("company")}
- onNext={() => setStep("adapter")}
- primaryLabel="Next"
- />
- )}
- {step === "adapter" && (
- setStep("agent")}
- onNext={handleHireAgent}
- loading={flow.loading}
- />
- )}
- {step === "task" && (
- setStep("adapter")}
- onGetStarted={handleGetStarted}
- loading={flow.loading}
- error={flow.error}
- />
- )}
- >
- )}
-
- );
-}
diff --git a/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx b/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx
deleted file mode 100644
index dfeb4c00023..00000000000
--- a/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { lazy, Suspense, type ReactNode } from "react";
-import { AnimatePresence, motion } from "motion/react";
-import { X } from "lucide-react";
-import { cn } from "@/lib/utils";
-import { STEP_EASE } from "./onboarding-motion";
-
-// three.js is large, so the orbiting-paperclip canvas is code-split: it only
-// downloads when the auth screens actually render.
-const PaperclipOrbit3D = lazy(() =>
- import("./PaperclipOrbit3D").then((m) => ({ default: m.PaperclipOrbit3D })),
-);
-
-/** Duration of the hand-off out of the auth screens (modal + backdrop together). */
-export const AUTH_EXIT_DURATION = 0.35;
-
-/**
- * The orbiting-paperclip layer plus its scrim, as a non-interactive fill layer.
- * Used behind the auth screens (cloud) and behind the welcome screen (local,
- * which skips auth entirely). The scrim keeps the page calm without muting the
- * gradient; panels above it are translucent so the motif reads through.
- */
-export function PaperclipBackdropLayer({ className }: { className?: string }) {
- return (
-
-
-
-
- {/* Very slight darkening scrim — enough to settle the backdrop behind the
- panels without muting the gradient. Applies to every screen that shows
- the paperclip (cloud auth screens + the local welcome screen), since
- both render through this layer. */}
-
-
- );
-}
-
-/**
- * Full-screen shell for the onboarding auth screens (create account / verify
- * code), with the orbiting 3D paperclip behind the card.
- *
- * On completion (`visible` → false) the card settles down and fades while the
- * backdrop fades out at the same time — a quick, subtle hand-off into whatever
- * comes next. Render this around the auth screens and flip `visible` when the
- * user finishes signing up or logging in.
- */
-export function OnboardingAuthBackdrop({
- visible,
- onClose,
- children,
-}: {
- visible: boolean;
- /** Optional dismiss affordance, matching OnboardingScaffold's close button. */
- onClose?: () => void;
- children: ReactNode;
-}) {
- return (
-
- {visible ? (
-
- {/* Orbiting paperclip backdrop — sits behind the card, non-interactive.
- It fades with the container; a dimming overlay keeps the card
- legible over the bright gradient. */}
-
-
-
-
- {onClose ? (
-
-
-
- ) : null}
-
- {/* The card lowers slightly and fades on the way out. */}
-
- {children}
-
-
- ) : null}
-
- );
-}
diff --git a/ui/src/components/onboarding/OnboardingAuthScreens.tsx b/ui/src/components/onboarding/OnboardingAuthScreens.tsx
deleted file mode 100644
index eeb06dc3b0f..00000000000
--- a/ui/src/components/onboarding/OnboardingAuthScreens.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-// Presentational-only auth screens (create account + email OTP), ported from
-// the prototype onto the repo's design tokens. These are NOT wired to real
-// auth — the app authenticates via better-auth upstream of onboarding. They
-// exist so the full onboarding visual arc can be previewed. See the preview
-// harness (onboarding-preview-main.tsx).
-import { useRef, useState } from "react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { cn } from "@/lib/utils";
-import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
-
-function GoogleMark({ className }: { className?: string }) {
- return (
-
-
-
-
-
-
- );
-}
-
-function GithubMark({ className }: { className?: string }) {
- return (
-
-
-
- );
-}
-
-function SocialButton({ mark, label }: { mark: React.ReactNode; label: string }) {
- return (
-
- {mark}
- {label}
-
- );
-}
-
-/** Create-account screen (visual only). */
-export function AccountScreen({ onContinue }: { onContinue?: () => void }) {
- return (
-
-
-
-
-
- Email
-
-
- We'll send you a 6-digit code to confirm your email.
-
-
-
- Password
-
- At least 8 characters.
-
-
- Continue
-
-
-
-
- Or continue with
-
-
-
- } label="Google" />
- } label="GitHub" />
-
-
- Already have an account?{" "}
- Sign in
-
-
-
- );
-}
-
-/** Email OTP confirmation screen (visual only). */
-export function OtpScreen({ email, onContinue }: { email?: string; onContinue?: () => void }) {
- const [vals, setVals] = useState(["", "", "", "", "", ""]);
- const refs = useRef>([]);
-
- function setAt(i: number, v: string) {
- setVals((a) => a.map((x, k) => (k === i ? v : x)));
- }
- function onChange(i: number, raw: string) {
- const v = raw.replace(/\D/g, "").slice(0, 1);
- setAt(i, v);
- if (v && i < 5) refs.current[i + 1]?.focus();
- }
- function onKeyDown(i: number, e: React.KeyboardEvent) {
- if (e.key === "Backspace" && !vals[i] && i > 0) refs.current[i - 1]?.focus();
- }
-
- const filled = vals.every(Boolean);
-
- return (
-
-
-
- Enter the 6-digit code we sent to{" "}
- {email || "your inbox"} .
- >
- }
- center
- />
-
- {vals.map((v, i) => (
- {
- refs.current[i] = el;
- }}
- inputMode="numeric"
- maxLength={1}
- value={v}
- onChange={(e) => onChange(i, e.target.value)}
- onKeyDown={(e) => onKeyDown(i, e)}
- className="size-(--sz-52px) rounded-md border border-input bg-muted/30 text-center font-mono text-xl font-semibold text-foreground outline-none transition-shadow focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-(length:--rad-3)"
- />
- ))}
-
-
- Continue
-
-
- Didn't get it?{" "}
- Resend code
- {" · "}
-
- Use a different email
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/OnboardingPrimitives.tsx b/ui/src/components/onboarding/OnboardingPrimitives.tsx
deleted file mode 100644
index 8e866e6755b..00000000000
--- a/ui/src/components/onboarding/OnboardingPrimitives.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-// Shared presentational primitives for the onboarding flow. Ported from the
-// prototype's hand-written CSS onto the repo's design tokens (semantic colors,
-// Tailwind v4). No backend logic lives here — these are pure view pieces.
-import type { ReactNode } from "react";
-import { Check } from "lucide-react";
-import { cn } from "@/lib/utils";
-import type { ConnectorOption } from "./onboarding-data";
-
-/**
- * The centered card frame every onboarding step sits in.
- *
- * `translucent` renders the panel at 95% so an animated backdrop (the orbiting
- * paperclip on the auth + welcome screens) reads through it, per the design.
- */
-export function OnboardingCard({
- children,
- className,
- translucent,
-}: {
- children: ReactNode;
- className?: string;
- translucent?: boolean;
-}) {
- return (
-
- {children}
-
- );
-}
-
-/** Display heading + supporting lede, ported from the prototype's hero type. */
-export function OnboardingHeading({
- title,
- lede,
- center,
-}: {
- title: ReactNode;
- lede?: ReactNode;
- center?: boolean;
-}) {
- return (
-
-
{title}
- {lede ? (
-
{lede}
- ) : null}
-
- );
-}
-
-/** Segmented progress bar with "Step N of M" meta. `total` defaults to 3. */
-export function Stepper({ step, total = 3 }: { step: number; total?: number }) {
- return (
-
-
- {Array.from({ length: total }, (_, i) => i + 1).map((s) => (
-
- ))}
-
-
- Step {step} of {total}
-
-
- );
-}
-
-/** Selectable pill chip (mission suggestions). */
-export function Chip({
- label,
- active,
- onClick,
-}: {
- label: string;
- active: boolean;
- onClick: () => void;
-}) {
- return (
-
- {label}
-
- );
-}
-
-/** Large selectable option card (first-task choices). */
-export function ChoiceCard({
- icon,
- title,
- description,
- selected,
- onClick,
-}: {
- icon: ReactNode;
- title: string;
- description: string;
- selected: boolean;
- onClick: () => void;
-}) {
- return (
-
-
- {icon}
-
-
- {title}
-
- {description}
-
-
-
-
- );
-}
-
-/** A single connector row with a colored glyph and connect/connected toggle. */
-export function ConnectorRow({
- connector,
- connected,
- onToggle,
-}: {
- connector: ConnectorOption;
- connected: boolean;
- onToggle: () => void;
-}) {
- return (
-
-
- {connector.glyph}
-
-
- {connector.name}
- {connector.description}
-
-
- {connected ? "Connected" : "Connect"}
-
-
- );
-}
diff --git a/ui/src/components/onboarding/OnboardingScaffold.tsx b/ui/src/components/onboarding/OnboardingScaffold.tsx
deleted file mode 100644
index 4ef9ceef557..00000000000
--- a/ui/src/components/onboarding/OnboardingScaffold.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import type { ReactNode } from "react";
-import { AnimatePresence, MotionConfig, motion } from "motion/react";
-import { X } from "lucide-react";
-import { stepMotion } from "./onboarding-motion";
-
-/**
- * Full-screen animated shell shared by both onboarding flows. Owns the close
- * button and the single `AnimatePresence`/keyed `motion.div` crossfade, so both
- * flows animate step transitions identically. `stepKey` must change whenever the
- * rendered step changes (that's what drives the enter/exit).
- */
-export function OnboardingScaffold({
- stepKey,
- onClose,
- children,
-}: {
- stepKey: string;
- onClose?: () => void;
- children: ReactNode;
-}) {
- return (
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/PaperclipOrbit3D.tsx b/ui/src/components/onboarding/PaperclipOrbit3D.tsx
deleted file mode 100644
index 971ef51bfc0..00000000000
--- a/ui/src/components/onboarding/PaperclipOrbit3D.tsx
+++ /dev/null
@@ -1,335 +0,0 @@
-// Orbiting 3D paperclip — the animated backdrop behind the onboarding auth
-// screens. Ported from the Paperclip graphic generator's 3D tab
-// (paperclip-gen.vercel.app) with its default settings plus auto-orbit on:
-// the same SVG-derived clip path, tube/cap geometry, "soft" gradient shader,
-// and orbit math, so the motif matches the generator exactly.
-//
-// three.js is heavy, so this module is loaded lazily (see OnboardingBackdrop).
-import { useEffect, useRef } from "react";
-import * as THREE from "three";
-
-// ── Generator defaults (3D tab) ───────────────────────────────────────────
-const TUBE_RADIUS = 0.225;
-const TUBE_SEG = 48; // radial segments
-const PATH_SEG = 300; // segments along the clip path
-const CAPS = true; // hemispherical caps
-const LIGHT_SOFT = 0;
-const LIGHT_ANGLE = 3.87;
-const EMISSIVE = 1;
-const CAM_DIST = 5;
-const FOV = 45;
-/** Auto-orbit speed. 0.5 was the generator default; slowed 25% to 0.375. */
-const ROT_SPEED = 0.375;
-/** Tilt oscillation rate. Slowed 25% alongside ROT_SPEED so the orbit's
- * rotation and tilt stay in the same relationship, just 25% calmer. */
-const TILT_RATE = 0.2625;
-/** Tilt amplitude of the orbit. Pace is set by the sin() frequency below, so
- * raising this swings further without speeding the motion up. */
-const AXIS_TILT = 1.2;
-/** Clip is scaled up 30% from the generator's default framing. */
-const CLIP_UPSCALE = 1.3;
-
-// Colour: instead of the generator's "animate gradient" (which scrolls the
-// gradient ALONG the clip), the gradient stops hold their position and the two
-// stops slowly crossfade through this palette, so the whole clip cycles hue as
-// it orbits. Drawn from the brand agent-gradient stops, limited to the
-// saturated ones — the brand pastels (cream/peach/lavender) read as washed-out
-// grey against the dark backdrop.
-const PALETTE: Array<[string, string]> = [
- ["#4fbcba", "#3aa35c"], // teal → green (the reference comp's colourway)
- ["#3aa35c", "#f2d95f"], // green → yellow
- ["#e3a21a", "#e94b27"], // amber → orange-red
- ["#ee79a1", "#bd7ff0"], // pink → purple
- ["#7eb6e3", "#3355ff"], // light blue → blue
- ["#3355ff", "#cc3388"], // blue → magenta (generator default)
-];
-/** Seconds each palette pair holds before crossfading into the next. */
-const PALETTE_CYCLE_SECONDS = 9;
-/**
- * Static gradient position along the path (no scrolling — "animate gradient" is
- * off). Must stay 0: the shader wraps the gradient with mod(t, 1.0), and any
- * non-zero shift puts that wrap seam partway along the clip, which makes the
- * end caps (whose gradient-t is pinned to the path ends) sample across the seam
- * and read as a different colour from the tube beside them.
- */
-const GRADIENT_SHIFT = 0;
-
-// The clip motif: an SVG path sampled into a Catmull-Rom curve. Scale + center
-// come from the generator so proportions match.
-const CLIP_PATH_D =
- "M5.00146 4.99569V21.005C5.00146 22.1084 5.89689 23.0028 7.00146 23.0028C8.10603 23.0028 9.00147 22.1084 9.00147 21.005L9 4.99569C9 2.78893 7.20914 1 5 1C2.79086 1 1 2.78893 1 4.99569V21.005C1 24.3159 3.68695 27 7.00146 27C10.316 27 13.0029 24.3159 13.0029 21.005L13.0029 4.99569";
-const CLIP_CENTER = { cx: 7, cy: 14 };
-const CLIP_SCALE = 1 / 6.5;
-const CURVE_SAMPLES = 200;
-
-const VERTEX_SHADER = `
- attribute float aPathT;
- attribute float aCapT; // -1.0 for tube verts; explicit gradient-t for cap verts
- varying vec3 vWorldNormal;
- varying float vPathT;
- varying float vCapT;
- void main() {
- vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
- vPathT = aPathT;
- vCapT = aCapT;
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
- }
-`;
-
-const FRAGMENT_SHADER = `
- #define MAX_COLORS 8
- uniform vec3 uColors[MAX_COLORS];
- uniform int uNColors;
- uniform float uShift;
- uniform float uSoftness;
- uniform vec3 uLightDir;
- uniform float uEmissive;
- varying vec3 vWorldNormal;
- varying float vPathT;
- varying float vCapT;
-
- vec3 sampleGradAt(float t) {
- float n = float(uNColors);
- float nc = n;
- float pos = clamp(t * (n - 1.0), 0.0, n - 1.0001);
- int idx = int(pos);
- float f = smoothstep(0.0, 1.0, pos - float(idx));
- vec3 cols[MAX_COLORS];
- cols[0]=uColors[0];cols[1]=uColors[1];cols[2]=uColors[2];cols[3]=uColors[3];
- cols[4]=uColors[4];cols[5]=uColors[5];cols[6]=uColors[6];cols[7]=uColors[7];
- int i0 = int(mod(float(idx), nc));
- int i1 = int(mod(float(idx+1), nc));
- return mix(cols[i0], cols[i1], f);
- }
-
- void main() {
- float shiftT = uShift / (2.0 * 3.14159265);
- float rawT = (vCapT >= 0.0) ? vCapT : vPathT;
- float nCols = float(uNColors);
- float scaled = rawT * (nCols - 1.0) / nCols;
- float t = mod(scaled + shiftT, 1.0);
- vec3 baseColor = sampleGradAt(t);
-
- vec3 nor = normalize(vWorldNormal);
- float wrap = mix(0.0, 0.6, uSoftness);
- float NdotL = dot(nor, normalize(uLightDir));
- float diff = smoothstep(0.0, 1.0, clamp((NdotL + wrap) / (1.0 + wrap), 0.0, 1.0));
- float ambient = mix(0.3, 0.6, uSoftness);
- vec3 lit = baseColor * (ambient + diff * (1.0 - ambient));
- lit = mix(lit, baseColor, uEmissive * 0.85);
- lit = pow(max(lit, vec3(0.0)), vec3(1.0 / 2.2));
- gl_FragColor = vec4(lit, 1.0);
- }
-`;
-
-/** Sample the clip SVG path into a centered, scaled Catmull-Rom curve. */
-function buildClipCurve(): THREE.CatmullRomCurve3 {
- const svgNS = "http://www.w3.org/2000/svg";
- const svg = document.createElementNS(svgNS, "svg");
- svg.setAttribute("width", "0");
- svg.setAttribute("height", "0");
- const path = document.createElementNS(svgNS, "path");
- path.setAttribute("d", CLIP_PATH_D);
- svg.appendChild(path);
- document.body.appendChild(svg);
-
- const total = path.getTotalLength();
- const points: THREE.Vector3[] = [];
- for (let i = 0; i <= CURVE_SAMPLES; i++) {
- const p = path.getPointAtLength((i / CURVE_SAMPLES) * total);
- points.push(
- new THREE.Vector3(
- (p.x - CLIP_CENTER.cx) * CLIP_SCALE,
- -(p.y - CLIP_CENTER.cy) * CLIP_SCALE,
- 0,
- ),
- );
- }
- document.body.removeChild(svg);
- return new THREE.CatmullRomCurve3(points, false, "catmullrom", 0.1);
-}
-
-/** Hemispherical end cap oriented along `normal`, tagged with its gradient t. */
-function buildCap(
- center: THREE.Vector3,
- normal: THREE.Vector3,
- radius: number,
- radialSeg: number,
- pathT: number,
-): THREE.BufferGeometry {
- const geo = new THREE.SphereGeometry(
- radius,
- radialSeg,
- Math.ceil(radialSeg / 2),
- 0,
- Math.PI * 2,
- 0,
- Math.PI / 2,
- );
- const count = geo.attributes.position.count;
- geo.setAttribute("aPathT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
- geo.setAttribute("aCapT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
- const up = new THREE.Vector3(0, 1, 0);
- const quat = new THREE.Quaternion().setFromUnitVectors(up, normal.clone().normalize());
- geo.applyMatrix4(new THREE.Matrix4().makeRotationFromQuaternion(quat));
- geo.translate(center.x, center.y, center.z);
- return geo;
-}
-
-export function PaperclipOrbit3D({ className }: { className?: string }) {
- const hostRef = useRef(null);
-
- useEffect(() => {
- const host = hostRef.current;
- if (!host) return;
-
- // Respect reduced motion: render a single static frame instead of orbiting.
- const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
-
- let renderer: THREE.WebGLRenderer;
- try {
- renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
- } catch {
- return; // No WebGL — the backdrop simply stays empty.
- }
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- renderer.setClearColor(0x000000, 0);
- host.appendChild(renderer.domElement);
- renderer.domElement.style.width = "100%";
- renderer.domElement.style.height = "100%";
- renderer.domElement.style.display = "block";
-
- const scene = new THREE.Scene();
- const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100);
-
- // ── Material (generator's "soft" mode) ──
- // Two live stops; the render loop crossfades them through PALETTE.
- const uColors = Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1));
- const paletteColors = PALETTE.map(
- ([a, b]) => [new THREE.Color(a), new THREE.Color(b)] as const,
- );
- const stopA = new THREE.Color();
- const stopB = new THREE.Color();
- // Stops are declared as [A, B, B] (nColors 3) rather than [A, B]: the
- // shader maps path position through `rawT * (n-1)/n`, so with 2 colours the
- // clip would only reach the A→B midpoint. The repeated final stop lets the
- // gradient complete A→B across the clip while keeping t inside [0,1) — no
- // mod() wrap, so the end caps still match the tube.
- const material = new THREE.ShaderMaterial({
- uniforms: {
- uColors: { value: uColors },
- uNColors: { value: 3 },
- uShift: { value: GRADIENT_SHIFT },
- uSoftness: { value: LIGHT_SOFT },
- uLightDir: {
- value: new THREE.Vector3(
- Math.cos(LIGHT_ANGLE) * 0.7,
- 0.8,
- Math.sin(LIGHT_ANGLE) * 0.7,
- ).normalize(),
- },
- uEmissive: { value: EMISSIVE },
- },
- vertexShader: VERTEX_SHADER,
- fragmentShader: FRAGMENT_SHADER,
- side: THREE.DoubleSide,
- });
-
- // ── Geometry: tube along the clip curve + hemispherical caps ──
- const curve = buildClipCurve();
- const group = new THREE.Group();
- const geometries: THREE.BufferGeometry[] = [];
-
- const tube = new THREE.TubeGeometry(curve, PATH_SEG, TUBE_RADIUS, TUBE_SEG, false);
- // TubeGeometry lays out (PATH_SEG+1) rings of (TUBE_SEG+1) verts: aPathT is
- // the ring's position along the path; aCapT -1 marks "not a cap".
- {
- const count = tube.attributes.position.count;
- const pathT = new Float32Array(count);
- const capT = new Float32Array(count).fill(-1);
- const ring = TUBE_SEG + 1;
- for (let i = 0; i < count; i++) pathT[i] = Math.floor(i / ring) / PATH_SEG;
- tube.setAttribute("aPathT", new THREE.BufferAttribute(pathT, 1));
- tube.setAttribute("aCapT", new THREE.BufferAttribute(capT, 1));
- }
- geometries.push(tube);
-
- if (CAPS) {
- const pts = curve.getPoints(PATH_SEG);
- const startNormal = pts[1].clone().sub(pts[0]).normalize().negate();
- const endNormal = pts[pts.length - 1]
- .clone()
- .sub(pts[pts.length - 2])
- .normalize();
- geometries.push(buildCap(pts[0], startNormal, TUBE_RADIUS, TUBE_SEG, 0));
- geometries.push(buildCap(pts[pts.length - 1], endNormal, TUBE_RADIUS, TUBE_SEG, 1));
- }
-
- geometries.forEach((geo) => group.add(new THREE.Mesh(geo, material)));
- group.scale.setScalar(CLIP_UPSCALE);
- scene.add(group);
-
- const target = new THREE.Vector3(0, 0, 0);
-
- function resize() {
- const w = host!.clientWidth;
- const h = host!.clientHeight;
- if (w === 0 || h === 0) return;
- renderer.setSize(w, h, false);
- camera.aspect = w / h;
- camera.updateProjectionMatrix();
- }
- resize();
- const observer = new ResizeObserver(resize);
- observer.observe(host);
-
- // ── Orbit + animated gradient (generator's loop, auto-orbit on) ──
- const start = performance.now();
- let raf = 0;
- function frame() {
- const t = reduceMotion ? 0 : (performance.now() - start) / 1000;
- const theta = t * ROT_SPEED * 0.25;
- const phi = Math.PI / 2 + Math.sin(t * TILT_RATE) * AXIS_TILT;
- camera.position.set(
- target.x + CAM_DIST * Math.sin(phi) * Math.sin(theta),
- target.y + CAM_DIST * Math.cos(phi),
- target.z + CAM_DIST * Math.sin(phi) * Math.cos(theta),
- );
- camera.lookAt(target);
-
- // Slow colour cycle: ease between consecutive palette pairs so the whole
- // clip crossfades hue over time (the gradient itself stays put on the
- // path — the generator's scrolling "animate gradient" is off).
- const pos = (t / PALETTE_CYCLE_SECONDS) % paletteColors.length;
- const idx = Math.floor(pos);
- const raw = pos - idx;
- const f = raw * raw * (3 - 2 * raw); // smoothstep
- const from = paletteColors[idx];
- const to = paletteColors[(idx + 1) % paletteColors.length];
- // lerpHSL rotates hue instead of crossing through grey, so the clip stays
- // saturated mid-transition (plain RGB lerp muddies opposing hues).
- stopA.copy(from[0]).lerpHSL(to[0], f);
- stopB.copy(from[1]).lerpHSL(to[1], f);
- uColors[0].set(stopA.r, stopA.g, stopA.b);
- // Stops 1..7 all hold B (see the [A, B, B] note above).
- for (let i = 1; i < 8; i++) uColors[i].set(stopB.r, stopB.g, stopB.b);
-
- renderer.render(scene, camera);
- if (!reduceMotion) raf = requestAnimationFrame(frame);
- }
- frame();
-
- return () => {
- cancelAnimationFrame(raf);
- observer.disconnect();
- geometries.forEach((g) => g.dispose());
- material.dispose();
- renderer.dispose();
- if (renderer.domElement.parentNode === host) host.removeChild(renderer.domElement);
- };
- }, []);
-
- return
;
-}
-
-export default PaperclipOrbit3D;
diff --git a/ui/src/components/onboarding/onboarding-data.ts b/ui/src/components/onboarding/onboarding-data.ts
deleted file mode 100644
index 92a66d96f43..00000000000
--- a/ui/src/components/onboarding/onboarding-data.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-// Static option data for the onboarding flow, ported from the prototype
-// (paperclip-onboard/src/data.js). Connectors + models are presentational in
-// the first cut; wiring them to real integrations is a later refinement.
-import {
- DEFAULT_TASK_DESCRIPTION,
- DEFAULT_TASK_TITLE,
-} from "@/lib/onboarding-constants";
-
-export const MISSION_CHIPS = [
- "Build a SaaS product",
- "Launch a marketplace",
- "Scale a content business",
-];
-
-export const ROLE_OPTIONS = [
- "Chief of Staff",
- "Chief Technical Officer",
- "Head of Marketing",
- "Researcher",
- "Coder",
- "Designer",
- "Other",
-];
-
-export const ROLE_ACRONYMS: Record = {
- "Chief of Staff": "COS",
- "Chief Technical Officer": "CTO",
- "Head of Marketing": "HOM",
- Researcher: "RES",
- Coder: "CDR",
- Designer: "DES",
- Other: "OTH",
-};
-
-export interface ConnectorOption {
- name: string;
- description: string;
- /** Glyph background color. */
- color: string;
- /** Whether the glyph needs a border (for near-black glyphs). */
- border?: boolean;
- /** Short glyph text. */
- glyph: string;
-}
-
-export const CONNECTORS: ConnectorOption[] = [
- { name: "GitHub", description: "Code, PRs, issues", color: "#0a0a0a", border: true, glyph: "GH" },
- { name: "Google", description: "Gmail, Drive, Calendar", color: "#4285f4", glyph: "G" },
- { name: "Notion", description: "Docs and databases", color: "#0a0a0a", border: true, glyph: "N" },
- { name: "Linear", description: "Issues and projects", color: "#5e6ad2", glyph: "L" },
- { name: "Supabase", description: "Database and auth", color: "#3ecf8e", glyph: "S" },
- { name: "Slack", description: "Team messaging", color: "#611f69", glyph: "#" },
- { name: "Stripe", description: "Payments and billing", color: "#635bff", glyph: "$" },
- { name: "Vercel", description: "Deploys and hosting", color: "#0a0a0a", border: true, glyph: "▲" },
-];
-
-export type FirstTaskChoice = "hiring" | "strategy" | "custom";
-
-/** Build the first-task title/description from the chosen option (shared by both flows). */
-export function firstTaskPayload(
- choice: FirstTaskChoice,
- custom: string,
-): { title: string; description: string } {
- switch (choice) {
- case "hiring":
- return { title: DEFAULT_TASK_TITLE, description: DEFAULT_TASK_DESCRIPTION };
- case "strategy":
- return {
- title: "Write a one-page team strategy",
- description:
- "You are the CEO. Turn the company mission into a one-page strategy: the goals, the bets you're making, and how you'll measure progress.",
- };
- case "custom":
- return {
- title: custom.trim() || "First task",
- description: custom.trim(),
- };
- }
-}
diff --git a/ui/src/components/onboarding/onboarding-motion.ts b/ui/src/components/onboarding/onboarding-motion.ts
deleted file mode 100644
index 706e96b1116..00000000000
--- a/ui/src/components/onboarding/onboarding-motion.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// Shared motion constants for the onboarding flows (cloud + local). Extracted
-// from the original OnboardingFlow so both flow containers and the shared step
-// views animate identically. Reduced-motion is honored globally via
-// in OnboardingScaffold.
-
-/** Step crossfade easing (also reused by in-step reveals). */
-export const STEP_EASE = [0.16, 1, 0.3, 1] as const;
-
-/** Per-step enter/exit crossfade used by the scaffold's keyed motion.div. */
-export const stepMotion = {
- initial: { opacity: 0, y: 8 },
- animate: { opacity: 1, y: 0 },
- exit: { opacity: 0, y: -8 },
- transition: { duration: 0.28, ease: STEP_EASE },
-};
-
-// Agent-step dashed-pill entrance: fades + scales up from 50% from its center
-// (no y offset, which would bias growth upward), with a spring bounce on
-// landing.
-export const CAPSULE_ENTER_DURATION = 1.0;
-export const capsuleMotion = {
- initial: { opacity: 0, scale: 0.5 },
- animate: { opacity: 1, scale: 1 },
- transition: { type: "spring" as const, duration: CAPSULE_ENTER_DURATION, bounce: 0.4 },
-};
-
-// Agent-step name/role reveal: the capsule slide-up (height growth) and the
-// label fade share this duration; the fade is staggered by 25% of it.
-export const PREVIEW_REVEAL_DURATION = 0.45;
-
-// Step 2 → 3 capsule hand-off: on "Create" the capsule eases out (scale to
-// 50%, fade to 0) with the exiting step, then resurfaces on the task step —
-// scaling back to 100% on a spring so it lands into place, fade eased to match.
-// Exit duration mirrors the step transition so they travel together.
-export const capsuleHandoffExit = {
- scale: 0.5,
- opacity: 0,
- transition: { duration: 0.28, ease: STEP_EASE },
-};
-export const capsuleHeroMotion = {
- initial: { scale: 0.5, opacity: 0 },
- animate: { scale: 1, opacity: 1 },
- transition: {
- // Softer spring = a slower, more noticeable scale-up that still lands with
- // a natural settle; fade lengthened to travel with it.
- scale: { type: "spring" as const, stiffness: 150, damping: 16 },
- opacity: { duration: 0.55, ease: STEP_EASE },
- },
-};
diff --git a/ui/src/components/onboarding/steps/AdapterStep.tsx b/ui/src/components/onboarding/steps/AdapterStep.tsx
deleted file mode 100644
index 8164f4bdba3..00000000000
--- a/ui/src/components/onboarding/steps/AdapterStep.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { useMemo } from "react";
-import { motion } from "motion/react";
-import { cn } from "@/lib/utils";
-import { listUIAdapters } from "@/adapters";
-import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
-import { isVisualAdapterChoice } from "@/adapters/metadata";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { AgentCapsule } from "../../AgentCapsule";
-import { OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
-import { FooterNav } from "../FooterNav";
-import { AgentPreview } from "../AgentPreview";
-import { capsuleHandoffExit, capsuleHeroMotion } from "../onboarding-motion";
-
-const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
-
-function visualAdapters() {
- return listUIAdapters()
- .filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && isVisualAdapterChoice(a.type))
- .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }))
- .filter((a) => !a.comingSoon);
-}
-
-/**
- * Local-flow only: pick the model/adapter the first agent runs on. Recommended
- * options are surfaced as cards; the rest are grouped in an "Additional models"
- * dropdown. Selecting from either sets the same adapterType.
- */
-export function AdapterStep({
- adapterType,
- onAdapterChange,
- agentName,
- agentRole,
- onBack,
- onNext,
- loading,
- step,
- total,
-}: {
- adapterType: string;
- onAdapterChange: (type: string) => void;
- agentName: string;
- agentRole: string;
- onBack: () => void;
- onNext: () => void;
- loading?: boolean;
- step: number;
- total?: number;
-}) {
- const { recommended, additional } = useMemo(() => {
- const all = visualAdapters();
- return {
- recommended: all.filter((a) => a.recommended),
- // Top handful of other models beyond the recommended ones.
- additional: all.filter((a) => !a.recommended).slice(0, 5),
- };
- }, []);
-
- // The dropdown reflects the selection only when it isn't one of the cards.
- const additionalValue = additional.some((a) => a.type === adapterType) ? adapterType : undefined;
-
- return (
-
-
-
- {/* Carried-over outlined capsule from the agent step: enters with the
- same hero scale/fade as the task pill and hands off (scale down) on
- exit, but stays "configured" (outlined) — no coming-alive here. */}
-
-
-
- {recommended.map((opt) => {
- const selected = adapterType === opt.type;
- return (
- onAdapterChange(opt.type)}
- className={cn(
- "relative flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
- selected
- ? "border-foreground bg-accent"
- : "border-border hover:border-muted-foreground",
- )}
- >
-
- Recommended
-
-
- {opt.label}
-
- {opt.description}
-
-
- );
- })}
-
-
- {additional.length > 0 ? (
-
-
- Additional models
-
-
-
-
-
-
- {additional.map((opt) => (
-
- {opt.label}
-
- ))}
-
-
-
- ) : null}
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/steps/AgentStep.tsx b/ui/src/components/onboarding/steps/AgentStep.tsx
deleted file mode 100644
index d42a1ab39c3..00000000000
--- a/ui/src/components/onboarding/steps/AgentStep.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { motion } from "motion/react";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { AgentCapsule } from "../../AgentCapsule";
-import { ROLE_OPTIONS } from "../onboarding-data";
-import { OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
-import { FooterNav } from "../FooterNav";
-import { AgentPreview } from "../AgentPreview";
-import { capsuleHandoffExit, capsuleMotion } from "../onboarding-motion";
-
-/** Create-your-first-agent step: role select + optional name, with the capsule. */
-export function AgentStep({
- agentRole,
- agentName,
- onRoleChange,
- onNameChange,
- onBack,
- onNext,
- loading,
- step,
- total,
- primaryLabel = "Create",
- loadingLabel = "Creating...",
-}: {
- agentRole: string;
- agentName: string;
- onRoleChange: (value: string) => void;
- onNameChange: (value: string) => void;
- onBack: () => void;
- onNext: () => void;
- loading?: boolean;
- step: number;
- total?: number;
- /** CTA label — cloud hires here ("Create"); local advances to the adapter step ("Next"). */
- primaryLabel?: string;
- loadingLabel?: string;
-}) {
- const previewVisible = Boolean(agentName || agentRole);
- return (
-
-
-
-
-
-
-
- Role
-
-
-
-
-
- {ROLE_OPTIONS.map((r) => (
-
- {r}
-
- ))}
-
-
-
-
-
- Name (optional)
-
- onNameChange(e.target.value)}
- />
-
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/steps/CompanyStep.tsx b/ui/src/components/onboarding/steps/CompanyStep.tsx
deleted file mode 100644
index 2941d04d94f..00000000000
--- a/ui/src/components/onboarding/steps/CompanyStep.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { useState } from "react";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Label } from "@/components/ui/label";
-import { MISSION_CHIPS } from "../onboarding-data";
-import { Chip, OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
-import { FooterNav } from "../FooterNav";
-
-/** Company name + mission step. */
-export function CompanyStep({
- companyName,
- onCompanyNameChange,
- mission,
- onMissionChange,
- onBack,
- onNext,
- loading,
- step,
- total,
-}: {
- companyName: string;
- onCompanyNameChange: (value: string) => void;
- mission: string;
- onMissionChange: (value: string) => void;
- onBack: () => void;
- onNext: () => void;
- loading?: boolean;
- step: number;
- total?: number;
-}) {
- const [activeChip, setActiveChip] = useState(null);
- return (
-
-
-
-
-
- Name
- onCompanyNameChange(e.target.value)}
- autoFocus
- />
-
-
- What is the mission?
-
-
- {MISSION_CHIPS.map((chip) => (
- {
- onMissionChange(chip);
- setActiveChip(chip);
- }}
- />
- ))}
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/steps/EmailStep.tsx b/ui/src/components/onboarding/steps/EmailStep.tsx
deleted file mode 100644
index edd2c47ded3..00000000000
--- a/ui/src/components/onboarding/steps/EmailStep.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { ShieldCheck } from "lucide-react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Label } from "@/components/ui/label";
-import { OnboardingCard, OnboardingHeading } from "../OnboardingPrimitives";
-
-/**
- * Local-flow only: a gentle, OPTIONAL email ask before the numbered steps, with
- * an explicit privacy assurance. Both Continue and Skip advance; the email is
- * collected into container state (persistence is out of scope for now).
- */
-export function EmailStep({
- email,
- onEmailChange,
- onContinue,
- onSkip,
-}: {
- email: string;
- onEmailChange: (value: string) => void;
- onContinue: () => void;
- onSkip: () => void;
-}) {
- return (
-
-
-
-
-
- Email (optional)
-
- onEmailChange(e.target.value)}
- autoFocus
- />
-
-
-
-
- We'll never use this for marketing or share it with third parties — it's only for
- the occasional product update.
-
-
-
-
- Continue
-
-
- Skip for now
-
-
-
-
- );
-}
diff --git a/ui/src/components/onboarding/steps/StartStep.tsx b/ui/src/components/onboarding/steps/StartStep.tsx
deleted file mode 100644
index e0dcbdeab91..00000000000
--- a/ui/src/components/onboarding/steps/StartStep.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { Mail, PlusCircle } from "lucide-react";
-import { cn } from "@/lib/utils";
-
-/**
- * Welcome / choose-a-path screen (unnumbered). "Set up" advances the flow (the
- * container decides the next step); "Join an existing team" is stubbed.
- *
- * Cards are translucent so the orbiting-paperclip backdrop reads through them
- * (local flow, which skips the auth screens and leads with this screen).
- */
-export function StartStep({ onSetup }: { onSetup: () => void }) {
- return (
-
-
- Welcome to Paperclip!
-
-
- }
- title="Set up Paperclip for your company or team"
- description="Create a new organization, build your first agent, and assign its first task."
- onClick={onSetup}
- />
- }
- title="Join an existing company or team"
- description="Have an invite? Enter your team's join code to come aboard."
- onClick={() => {}}
- disabled
- />
-
-
- );
-}
-
-/** A square choice tile: icon, title and description all inside the card. */
-function StartOption({
- icon,
- title,
- description,
- onClick,
- disabled,
-}: {
- icon: React.ReactNode;
- title: string;
- description: string;
- onClick: () => void;
- disabled?: boolean;
-}) {
- return (
-
- {icon}
-
- {title}
- {description}
-
-
- );
-}
diff --git a/ui/src/components/onboarding/steps/TaskStep.tsx b/ui/src/components/onboarding/steps/TaskStep.tsx
deleted file mode 100644
index 1158dbff25c..00000000000
--- a/ui/src/components/onboarding/steps/TaskStep.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import { AnimatePresence, motion } from "motion/react";
-import { Building2, Pencil, UserPlus } from "lucide-react";
-import { Textarea } from "@/components/ui/textarea";
-import { AgentCapsule } from "../../AgentCapsule";
-import type { FirstTaskChoice } from "../onboarding-data";
-import { ChoiceCard, OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
-import { FooterNav } from "../FooterNav";
-import { AgentPreview } from "../AgentPreview";
-import { capsuleHeroMotion, STEP_EASE } from "../onboarding-motion";
-
-/** Assign-the-first-task step: three choice cards + a custom-task reveal. */
-export function TaskStep({
- agentName,
- agentRole,
- taskChoice,
- onSelectChoice,
- customTask,
- onCustomTaskChange,
- onBack,
- onGetStarted,
- loading,
- error,
- step,
- total,
-}: {
- agentName: string;
- agentRole: string;
- taskChoice: FirstTaskChoice | null;
- onSelectChoice: (choice: FirstTaskChoice) => void;
- customTask: string;
- onCustomTaskChange: (value: string) => void;
- onBack: () => void;
- onGetStarted: () => void;
- loading?: boolean;
- error?: string | null;
- step: number;
- total?: number;
-}) {
- // Sentence-initial in the lede, so the fallback is capitalized.
- const agentLabel = agentName || agentRole || "Your agent";
- return (
-
-
-
-
-
Where should we start? {agentLabel} is ready for instructions.>}
- center
- />
- {/* The reveal sits OUTSIDE the gap-3 flex and owns its own top spacing
- (pt-3), so collapsing to height 0 leaves no phantom flex gap to snap
- away on unmount — the collapse mirrors the expand exactly. overflow
- is hidden only while animating and visible at rest so the textarea's
- focus ring isn't clipped. */}
-
-
- }
- title="Create a hiring plan"
- description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
- selected={taskChoice === "hiring"}
- onClick={() => onSelectChoice("hiring")}
- />
- }
- title="Write a team strategy doc"
- description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
- selected={taskChoice === "strategy"}
- onClick={() => onSelectChoice("strategy")}
- />
- }
- title="Write your own task…"
- description="Describe anything else you want done first."
- selected={taskChoice === "custom"}
- onClick={() => onSelectChoice("custom")}
- />
-
-
- {taskChoice === "custom" && (
-
-
-
-
- )}
-
-
- {error ? {error}
: null}
-
-
-
- );
-}
diff --git a/ui/src/hooks/useOnboardingFlow.test.tsx b/ui/src/hooks/useOnboardingFlow.test.tsx
deleted file mode 100644
index b51a90c4092..00000000000
--- a/ui/src/hooks/useOnboardingFlow.test.tsx
+++ /dev/null
@@ -1,162 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react";
-import { flushSync } from "react-dom";
-import { createRoot, type Root } from "react-dom/client";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-
-// The hook only needs a company id and the two agent endpoints for these cases;
-// everything else is mocked so the probe-cache logic is exercised in isolation.
-const testEnvironment = vi.hoisted(() => vi.fn());
-const hire = vi.hoisted(() => vi.fn());
-
-vi.mock("@tanstack/react-query", () => ({
- useQueryClient: () => ({ invalidateQueries: vi.fn() }),
-}));
-vi.mock("@/context/CompanyContext", () => ({
- useCompany: () => ({ setSelectedCompanyId: vi.fn() }),
-}));
-vi.mock("@/api/agents", () => ({
- agentsApi: {
- testEnvironment,
- hire,
- instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })),
- saveInstructionsFile: vi.fn(async () => undefined),
- update: vi.fn(async () => undefined),
- },
-}));
-vi.mock("@/api/companies", () => ({ companiesApi: { create: vi.fn() } }));
-vi.mock("@/api/goals", () => ({ goalsApi: { create: vi.fn() } }));
-vi.mock("@/api/approvals", () => ({ approvalsApi: { approve: vi.fn() } }));
-vi.mock("@/api/issues", () => ({ issuesApi: { create: vi.fn() } }));
-vi.mock("@/api/projects", () => ({ projectsApi: { create: vi.fn(), list: vi.fn() } }));
-
-import { useOnboardingFlow, type OnboardingFlow } from "./useOnboardingFlow";
-
-const INSTRUCTIONS = {
- companyName: "Acme",
- companyGoal: "Build things",
- growPath: false,
- growWorkflows: "",
- growPainPoints: "",
- growAutomate: "",
- q1: "",
- q2: "",
- q3: "",
- q4: "",
-};
-
-function adapter(adapterType: string) {
- return { adapterType, model: "", command: "", args: "", url: "" };
-}
-
-describe("useOnboardingFlow adapter environment probe", () => {
- let container: HTMLDivElement;
- let root: Root | null = null;
- let flow: OnboardingFlow;
-
- function Probe() {
- flow = useOnboardingFlow({ createdCompanyId: "company-1" });
- return null;
- }
-
- beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
- flushSync(() => {
- root!.render( );
- });
- testEnvironment.mockResolvedValue({ status: "pass" });
- hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
- });
-
- afterEach(() => {
- flushSync(() => {
- root?.unmount();
- });
- root = null;
- container.remove();
- vi.clearAllMocks();
- });
-
- it("re-probes when the adapter changed since the cached probe ran", async () => {
- // The local flow lets the user pick a different adapter and retry after a
- // failed hire. The first adapter's verdict must not stand in for the second.
- await act(async () => {
- await flow.runAdapterEnvironmentTest(adapter("claude_local"));
- });
- expect(testEnvironment).toHaveBeenCalledTimes(1);
-
- hire.mockRejectedValueOnce(new Error("hire failed"));
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- // Same adapter — the cached probe is reused rather than re-run.
- expect(testEnvironment).toHaveBeenCalledTimes(1);
-
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("codex_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- expect(testEnvironment).toHaveBeenCalledTimes(2);
- expect(testEnvironment.mock.calls[1][1]).toBe("codex_local");
- // The probed config and the hired config are the same object of record.
- expect(hire.mock.calls[1][1].adapterConfig).toEqual(
- testEnvironment.mock.calls[1][2].adapterConfig,
- );
- });
-
- it("probes when no cached result exists", async () => {
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- expect(testEnvironment).toHaveBeenCalledTimes(1);
- });
-
- it("clearAdapterEnvResult forces the next hire to re-probe", async () => {
- await act(async () => {
- await flow.runAdapterEnvironmentTest(adapter("claude_local"));
- });
- act(() => {
- flow.clearAdapterEnvResult();
- });
- expect(flow.adapterEnvResult).toBeNull();
-
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: true,
- });
- });
- expect(testEnvironment).toHaveBeenCalledTimes(2);
- });
-
- it("does not probe at all when requireEnvProbe is false (cloud flow)", async () => {
- await act(async () => {
- await flow.hireLeadAgent({
- agentName: "COS",
- adapter: adapter("claude_local"),
- instructions: INSTRUCTIONS,
- requireEnvProbe: false,
- });
- });
- expect(testEnvironment).not.toHaveBeenCalled();
- });
-});
diff --git a/ui/src/hooks/useOnboardingFlow.ts b/ui/src/hooks/useOnboardingFlow.ts
deleted file mode 100644
index 189692a82ab..00000000000
--- a/ui/src/hooks/useOnboardingFlow.ts
+++ /dev/null
@@ -1,483 +0,0 @@
-// Backend orchestration for onboarding, extracted from OnboardingWizard so a
-// new presentational onboarding flow can reuse the exact company/goal/agent/
-// issue creation logic without duplicating API calls or query invalidation.
-//
-// This hook owns the "created entity" ids and the adapter-environment probe
-// state; it does NOT own user-entered form state (company name, mission, agent
-// name, adapter selection) — callers pass those into the action functions. It
-// also does not navigate: launchFirstTask returns the target company prefix and
-// lets the caller route, keeping routing a UI concern.
-import { useState } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
-import { useCompany } from "@/context/CompanyContext";
-import { companiesApi } from "@/api/companies";
-import { goalsApi } from "@/api/goals";
-import { agentsApi } from "@/api/agents";
-import { approvalsApi } from "@/api/approvals";
-import { issuesApi } from "@/api/issues";
-import { projectsApi } from "@/api/projects";
-import { queryKeys } from "@/lib/queryKeys";
-import { parseOnboardingGoalInput } from "@/lib/onboarding-goal";
-import { composeCeoInstructions } from "@/lib/ceo-instructions";
-import { buildNewAgentRuntimeConfig } from "@/lib/new-agent-runtime-config";
-import {
- buildOnboardingIssuePayload,
- buildOnboardingProjectPayload,
- selectDefaultCompanyGoalId,
- selectReusableOnboardingProject,
-} from "@/lib/onboarding-launch";
-import {
- buildOnboardingAdapterConfig,
- type OnboardingAdapterConfigInput,
-} from "@/lib/onboarding-adapter-config";
-import {
- DEFAULT_TASK_DESCRIPTION,
- DEFAULT_TASK_TITLE,
-} from "@/lib/onboarding-constants";
-
-/** Adapter selection inputs shared by the env probe and the hire action. */
-export type AdapterInput = Omit;
-
-/** Context used to seed the lead agent's instructions file. */
-export interface OnboardingInstructionsContext {
- companyName: string;
- companyGoal: string;
- growPath: boolean;
- growWorkflows: string;
- growPainPoints: string;
- growAutomate: string;
- q1: string;
- q2: string;
- q3: string;
- q4: string;
-}
-
-export interface HireLeadAgentInput {
- agentName: string;
- adapter: AdapterInput;
- instructions: OnboardingInstructionsContext;
- /**
- * When true (local adapters), require a successful adapter-environment probe
- * before hiring. Mirrors the wizard: hire is aborted only if the probe cannot
- * run at all (returns null), not if it returns a fail status.
- */
- requireEnvProbe: boolean;
-}
-
-export interface CreatedOnboardingEntities {
- createdCompanyId: string | null;
- createdCompanyPrefix: string | null;
- createdAgentId: string | null;
- createdCompanyGoalId: string | null;
- createdProjectId: string | null;
- createdIssueRef: string | null;
-}
-
-export function useOnboardingFlow(initial?: Partial) {
- const queryClient = useQueryClient();
- const { setSelectedCompanyId } = useCompany();
-
- const [createdCompanyId, setCreatedCompanyId] = useState(
- initial?.createdCompanyId ?? null,
- );
- const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState(
- initial?.createdCompanyPrefix ?? null,
- );
- const [createdAgentId, setCreatedAgentId] = useState(
- initial?.createdAgentId ?? null,
- );
- const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState(
- initial?.createdCompanyGoalId ?? null,
- );
- const [createdProjectId, setCreatedProjectId] = useState(
- initial?.createdProjectId ?? null,
- );
- const [createdIssueRef, setCreatedIssueRef] = useState(
- initial?.createdIssueRef ?? null,
- );
-
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const [adapterEnvResult, setAdapterEnvResult] =
- useState(null);
- const [adapterEnvError, setAdapterEnvError] = useState(null);
- const [adapterEnvLoading, setAdapterEnvLoading] = useState(false);
- const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] = useState(false);
- const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
- /**
- * Identifies the adapter selection that produced `adapterEnvResult`. The local
- * flow lets the user pick a different adapter and retry after a failed hire,
- * so a cached probe result is only evidence about the adapter it actually
- * ran against — see `hireLeadAgent`.
- */
- const [adapterEnvProbedKey, setAdapterEnvProbedKey] = useState(null);
-
- function buildAdapterConfig(adapter: AdapterInput): Record {
- return buildOnboardingAdapterConfig({ ...adapter, forceUnsetAnthropicApiKey });
- }
-
- /**
- * Stable identity for a probe: the adapter type plus the exact config posted
- * to the environment-test endpoint. `buildOnboardingAdapterConfig` builds its
- * object in a fixed key order for a given adapter, so `JSON.stringify` is
- * stable across calls.
- */
- function adapterProbeKey(
- adapterType: string,
- adapterConfig: Record,
- ): string {
- return JSON.stringify([adapterType, adapterConfig]);
- }
-
- /**
- * Forget any cached probe result. Callers invoke this when the user changes
- * the adapter selection, so neither the UI nor `hireLeadAgent` keeps showing
- * or trusting a verdict about the adapter that is no longer selected.
- */
- function clearAdapterEnvResult(): void {
- setAdapterEnvResult(null);
- setAdapterEnvError(null);
- setAdapterEnvProbedKey(null);
- }
-
- /**
- * Create the company + its company-level goal from the mission. Guarded so
- * calling it again after a company exists is a no-op that returns the
- * already-created ids.
- */
- async function createCompanyAndGoal(input: {
- companyName: string;
- companyGoal: string;
- }): Promise<{ companyId: string; companyPrefix: string; goalId: string | null } | null> {
- if (createdCompanyId) {
- return {
- companyId: createdCompanyId,
- companyPrefix: createdCompanyPrefix ?? "",
- goalId: createdCompanyGoalId,
- };
- }
- setLoading(true);
- setError(null);
- try {
- const company = await companiesApi.create({ name: input.companyName.trim() });
- setCreatedCompanyId(company.id);
- setCreatedCompanyPrefix(company.issuePrefix);
- setSelectedCompanyId(company.id);
- queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
-
- const parsedGoal = parseOnboardingGoalInput(input.companyGoal);
- const goal = await goalsApi.create(company.id, {
- title: parsedGoal.title,
- ...(parsedGoal.description ? { description: parsedGoal.description } : {}),
- level: "company",
- status: "active",
- });
- setCreatedCompanyGoalId(goal.id);
- queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(company.id) });
-
- return { companyId: company.id, companyPrefix: company.issuePrefix, goalId: goal.id };
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to create company");
- return null;
- } finally {
- setLoading(false);
- }
- }
-
- async function runAdapterEnvironmentTest(
- adapter: AdapterInput,
- adapterConfigOverride?: Record,
- ): Promise {
- if (!createdCompanyId) {
- setAdapterEnvError(
- "Create or select a company before testing adapter environment.",
- );
- return null;
- }
- setAdapterEnvLoading(true);
- setAdapterEnvError(null);
- const adapterConfig = adapterConfigOverride ?? buildAdapterConfig(adapter);
- try {
- const result = await agentsApi.testEnvironment(createdCompanyId, adapter.adapterType, {
- adapterConfig,
- });
- setAdapterEnvResult(result);
- setAdapterEnvProbedKey(adapterProbeKey(adapter.adapterType, adapterConfig));
- return result;
- } catch (err) {
- setAdapterEnvError(
- err instanceof Error ? err.message : "Adapter environment test failed",
- );
- setAdapterEnvResult(null);
- setAdapterEnvProbedKey(null);
- return null;
- } finally {
- setAdapterEnvLoading(false);
- }
- }
-
- /**
- * Clear ANTHROPIC_API_KEY in the adapter config and re-probe. Mirrors the
- * wizard's remediation when a stray key overrides the Claude subscription.
- */
- async function unsetAnthropicApiKeyAndRetry(adapter: AdapterInput): Promise {
- if (!createdCompanyId || unsetAnthropicLoading) return;
- setUnsetAnthropicLoading(true);
- setError(null);
- setAdapterEnvError(null);
- setForceUnsetAnthropicApiKey(true);
-
- const configWithUnset = (() => {
- const config = buildAdapterConfig(adapter);
- const env =
- typeof config.env === "object" &&
- config.env !== null &&
- !Array.isArray(config.env)
- ? { ...(config.env as Record) }
- : {};
- env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
- config.env = env;
- return config;
- })();
-
- try {
- if (createdAgentId) {
- await agentsApi.update(createdAgentId, { adapterConfig: configWithUnset }, createdCompanyId);
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
- }
-
- const result = await runAdapterEnvironmentTest(adapter, configWithUnset);
- if (result?.status === "fail") {
- setError(
- "Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing.",
- );
- }
- } catch (err) {
- setError(
- err instanceof Error
- ? err.message
- : "Failed to unset ANTHROPIC_API_KEY and retry.",
- );
- } finally {
- setUnsetAnthropicLoading(false);
- }
- }
-
- /**
- * Hire the lead agent (role: ceo), auto-approve any board approval, and seed
- * its instructions file. Guarded so a second call after an agent exists is a
- * no-op. Returns null on failure (error is set) or when a required env probe
- * cannot run.
- */
- async function hireLeadAgent(
- input: HireLeadAgentInput,
- ): Promise<{ agentId: string } | null> {
- if (!createdCompanyId) return null;
- if (createdAgentId) return { agentId: createdAgentId };
-
- setLoading(true);
- setError(null);
- const adapterConfig = buildAdapterConfig(input.adapter);
- try {
- if (input.requireEnvProbe) {
- // Reuse the cached probe only when it ran against this exact adapter
- // selection. After a failed hire the user can go back and pick a
- // different adapter, and the previous adapter's verdict says nothing
- // about the new one.
- const cacheHit =
- adapterEnvResult !== null &&
- adapterEnvProbedKey === adapterProbeKey(input.adapter.adapterType, adapterConfig);
- const result = cacheHit
- ? adapterEnvResult
- : await runAdapterEnvironmentTest(input.adapter, adapterConfig);
- if (!result) return null;
- }
-
- const hire = await agentsApi.hire(createdCompanyId, {
- name: input.agentName.trim(),
- role: "ceo",
- adapterType: input.adapter.adapterType,
- adapterConfig,
- runtimeConfig: buildNewAgentRuntimeConfig(),
- });
- if (hire.approval) {
- await approvalsApi.approve(
- hire.approval.id,
- "Approved during onboarding first-agent setup.",
- );
- queryClient.invalidateQueries({
- queryKey: queryKeys.approvals.list(createdCompanyId),
- });
- }
- const agent = hire.agent;
- setCreatedAgentId(agent.id);
- queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
-
- // Seed the CEO's instructions file so the agent always has company
- // context + a hiring-plan output rule. Non-fatal on failure.
- try {
- const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId);
- await agentsApi.saveInstructionsFile(
- agent.id,
- {
- path: bundle.entryFile,
- content: composeCeoInstructions({
- companyName: input.instructions.companyName,
- companyGoal: input.instructions.companyGoal,
- growPath: input.instructions.growPath,
- growWorkflows: input.instructions.growWorkflows,
- growPainPoints: input.instructions.growPainPoints,
- growAutomate: input.instructions.growAutomate,
- q1: input.instructions.q1,
- q2: input.instructions.q2,
- q3: input.instructions.q3,
- q4: input.instructions.q4,
- }),
- },
- createdCompanyId,
- );
- } catch (err) {
- console.warn("Failed to seed CEO instructions:", err);
- }
-
- return { agentId: agent.id };
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to create agent");
- return null;
- } finally {
- setLoading(false);
- }
- }
-
- /**
- * Ensure the onboarding goal + project exist, create the first task assigned
- * to the lead agent, select the company, and return the target company prefix
- * for the caller to navigate to. Idempotent across the goal/project/issue it
- * creates. Requires a created company + agent.
- */
- async function launchFirstTask(input?: {
- title?: string;
- description?: string;
- }): Promise<{ companyId: string; companyPrefix: string | null } | null> {
- if (!createdCompanyId || !createdAgentId) {
- setError(
- "Onboarding state is incomplete. Please restart onboarding and try again.",
- );
- return null;
- }
- setLoading(true);
- setError(null);
- try {
- let goalId = createdCompanyGoalId;
- if (!goalId) {
- const goals = await goalsApi.list(createdCompanyId);
- goalId = selectDefaultCompanyGoalId(goals);
- setCreatedCompanyGoalId(goalId);
- }
-
- let projectId = createdProjectId;
- if (!projectId) {
- const projects = await projectsApi.list(createdCompanyId);
- const existingOnboardingProject = selectReusableOnboardingProject(projects);
- if (existingOnboardingProject) {
- projectId = existingOnboardingProject.id;
- } else {
- const project = await projectsApi.create(
- createdCompanyId,
- buildOnboardingProjectPayload(goalId),
- );
- projectId = project.id;
- queryClient.invalidateQueries({
- queryKey: queryKeys.projects.list(createdCompanyId),
- });
- }
- setCreatedProjectId(projectId);
- }
-
- if (!createdIssueRef) {
- const issue = await issuesApi.create(
- createdCompanyId,
- buildOnboardingIssuePayload({
- title: input?.title ?? DEFAULT_TASK_TITLE,
- description: input?.description ?? DEFAULT_TASK_DESCRIPTION,
- assigneeAgentId: createdAgentId,
- projectId,
- goalId,
- }),
- );
- setCreatedIssueRef(issue.identifier ?? issue.id);
- queryClient.invalidateQueries({
- queryKey: queryKeys.issues.list(createdCompanyId),
- });
- }
-
- setSelectedCompanyId(createdCompanyId);
- return { companyId: createdCompanyId, companyPrefix: createdCompanyPrefix };
- } catch (err) {
- setError(err instanceof Error ? err.message : "Failed to launch first task");
- return null;
- } finally {
- setLoading(false);
- }
- }
-
- function setCreatedCompany(company: { id: string; prefix: string | null }) {
- setCreatedCompanyId(company.id);
- setCreatedCompanyPrefix(company.prefix);
- }
-
- function reset() {
- setCreatedCompanyId(null);
- setCreatedCompanyPrefix(null);
- setCreatedAgentId(null);
- setCreatedCompanyGoalId(null);
- setCreatedProjectId(null);
- setCreatedIssueRef(null);
- setLoading(false);
- setError(null);
- setAdapterEnvResult(null);
- setAdapterEnvError(null);
- setAdapterEnvLoading(false);
- setAdapterEnvProbedKey(null);
- setForceUnsetAnthropicApiKey(false);
- setUnsetAnthropicLoading(false);
- }
-
- return {
- // created entity state
- createdCompanyId,
- createdCompanyPrefix,
- createdAgentId,
- createdCompanyGoalId,
- createdProjectId,
- createdIssueRef,
- setCreatedCompany,
- setCreatedCompanyPrefix,
-
- // status
- loading,
- error,
- setError,
-
- // adapter environment probe
- adapterEnvResult,
- adapterEnvError,
- adapterEnvLoading,
- forceUnsetAnthropicApiKey,
- unsetAnthropicLoading,
- buildAdapterConfig,
- runAdapterEnvironmentTest,
- clearAdapterEnvResult,
- unsetAnthropicApiKeyAndRetry,
-
- // actions
- createCompanyAndGoal,
- hireLeadAgent,
- launchFirstTask,
- reset,
- };
-}
-
-export type OnboardingFlow = ReturnType;
diff --git a/ui/src/index.css b/ui/src/index.css
index ca5dd2d3841..3b4b143346d 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -993,57 +993,38 @@
/* Agent capsule motif (PAP-119) — "the capsule is the agent".
Three states realized in the AgentCapsule component:
- - slot: static dashed outline (an empty agent slot)
+ - slot: dashed outline gently pulsing (an empty agent slot)
- configured: solid stroke (named/model picked, not yet live)
- - online: brand agent-gradient fill radiating outward from the center
- + online-pulse (the only pulsing state)
- prefers-reduced-motion skips the fill + pulses and renders the final
- state. */
-/* Radial outward reveal of the (linear-gradient) fill: a centered circular
- clip grows past the half-diagonal (~71%) so it covers the whole capsule. */
-@keyframes agent-cap-fill-radial {
- 0% { clip-path: circle(0% at 50% 50%); }
- 100% { clip-path: circle(75% at 50% 50%); }
+ - online: brand agent-gradient liquid rises to fill + green online-pulse
+ prefers-reduced-motion skips the liquid rise + pulses and renders the
+ final state. */
+@keyframes agent-cap-slot-pulse {
+ 0%, 100% { opacity: 0.5; }
+ 50% { opacity: 1; }
+}
+
+@keyframes agent-cap-rise {
+ 0% { height: 0; }
+ 100% { height: 100%; }
}
@keyframes agent-cap-online-pulse {
0%, 100% {
- box-shadow:
- 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent),
- 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
+ box-shadow: 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
transform: scale(1);
}
50% {
- box-shadow:
- 0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent),
- 0 0 0 14px color-mix(in oklab, #22c55e 7%, transparent);
+ box-shadow: 0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent);
transform: scale(1.03);
}
}
+.agent-cap-slot {
+ animation: agent-cap-slot-pulse 1.6s ease-in-out infinite;
+}
+
.agent-cap-liquid {
- /* Dramatic ease-in-out: the radial fill eases in, rushes, and eases out. */
- animation: agent-cap-fill-radial var(--agent-cap-reveal-duration, 1.4s)
- cubic-bezier(0.83, 0, 0.17, 1) forwards;
-}
-
-/* Online carryover: the solid white outline begins fading as soon as the fill
- starts so the two overlap toward the start (outline gone by the fill's
- midpoint). Shares the reveal duration so an onboarding-scoped override slows
- both together. */
-@keyframes agent-cap-stroke-carryover {
- 0% {
- opacity: 1;
- }
- 50%,
- 100% {
- opacity: 0;
- }
-}
-
-.agent-cap-stroke-carryover {
- animation: agent-cap-stroke-carryover var(--agent-cap-reveal-duration, 1.4s)
- linear forwards;
+ animation: agent-cap-rise 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.agent-cap-online {
@@ -1054,15 +1035,11 @@
Same breathing ring as the green pulse, recoloured to brand blue (#2563eb). */
@keyframes agent-cap-online-pulse-blue {
0%, 100% {
- box-shadow:
- 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent),
- 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
+ box-shadow: 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
transform: scale(1);
}
50% {
- box-shadow:
- 0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent),
- 0 0 0 14px color-mix(in oklab, #2563eb 9%, transparent);
+ box-shadow: 0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent);
transform: scale(1.03);
}
}
@@ -1078,6 +1055,7 @@
}
@media (prefers-reduced-motion: reduce) {
+ .agent-cap-slot,
.agent-cap-online,
.agent-cap-online-blue {
animation: none;
@@ -1087,11 +1065,7 @@
}
.agent-cap-liquid {
animation: none;
- clip-path: none;
- }
- .agent-cap-stroke-carryover {
- animation: none;
- opacity: 0;
+ height: 100%;
}
}
@@ -2350,8 +2324,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
* allow ui/src/pages/RunTranscriptUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
* allow ui/src/pages/InviteUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
* allow ui/src/pages/IssueChatUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
- * allow ui/src/components/onboarding/OnboardingAuthScreens.tsx — Google/GitHub official brand-mark SVG fills (third-party logo colors); brand identity values, not themeable roles
- * allow ui/src/components/onboarding/onboarding-data.ts — connector glyph colors are third-party brand hexes (GitHub/Google/Notion/Linear/…) rendered as logo-tile backgrounds; brand identity values, not themeable roles
* allow ui/src/pages/SystemNoticeUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
Test-fixture hex-literal policy (Batch 4, resolving TOKEN-AUDIT.md section
diff --git a/ui/src/lib/onboarding-adapter-config.test.ts b/ui/src/lib/onboarding-adapter-config.test.ts
deleted file mode 100644
index 91cb12fd351..00000000000
--- a/ui/src/lib/onboarding-adapter-config.test.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { buildOnboardingAdapterConfig } from "./onboarding-adapter-config";
-
-const baseInput = {
- model: "",
- command: "",
- args: "",
- url: "",
-};
-
-describe("buildOnboardingAdapterConfig", () => {
- it("skips permissions for claude_local", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "claude_local",
- forceUnsetAnthropicApiKey: false,
- });
- expect(config.dangerouslySkipPermissions).toBe(true);
- });
-
- it("forces ANTHROPIC_API_KEY empty for claude_local when requested", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "claude_local",
- forceUnsetAnthropicApiKey: true,
- });
- const env = config.env as Record;
- expect(env.ANTHROPIC_API_KEY).toEqual({ type: "plain", value: "" });
- });
-
- it("does not force ANTHROPIC_API_KEY when the flag is off", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "claude_local",
- forceUnsetAnthropicApiKey: false,
- });
- const env = config.env as Record | undefined;
- expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
- });
-
- it("only applies the ANTHROPIC_API_KEY override to claude_local", () => {
- const config = buildOnboardingAdapterConfig({
- ...baseInput,
- adapterType: "codex_local",
- forceUnsetAnthropicApiKey: true,
- });
- const env = config.env as Record | undefined;
- expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
- });
-});
diff --git a/ui/src/lib/onboarding-adapter-config.ts b/ui/src/lib/onboarding-adapter-config.ts
deleted file mode 100644
index 9ceb80f8b40..00000000000
--- a/ui/src/lib/onboarding-adapter-config.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-// Pure adapter-config builder for onboarding, extracted verbatim from
-// OnboardingWizard.buildAdapterConfig so the new onboarding flow and the
-// useOnboardingFlow hook can build an agent adapter config from user inputs
-// without duplicating the branch logic.
-import { getUIAdapter } from "@/adapters";
-import { defaultCreateValues } from "@/components/agent-config-defaults";
-import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
-import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
-import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
-import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
-
-export interface OnboardingAdapterConfigInput {
- adapterType: string;
- model: string;
- command: string;
- args: string;
- url: string;
- /**
- * When true and the adapter is claude_local, force ANTHROPIC_API_KEY to an
- * empty plain value so a subscription login is used instead of a stray env
- * key that overrides it.
- */
- forceUnsetAnthropicApiKey: boolean;
-}
-
-export function buildOnboardingAdapterConfig(
- input: OnboardingAdapterConfigInput,
-): Record {
- const { adapterType, model, command, args, url, forceUnsetAnthropicApiKey } = input;
- const adapter = getUIAdapter(adapterType);
- const config = adapter.buildAdapterConfig({
- ...defaultCreateValues,
- adapterType,
- model:
- adapterType === "gemini_local"
- ? model || DEFAULT_GEMINI_LOCAL_MODEL
- : adapterType === "cursor"
- ? model || DEFAULT_CURSOR_LOCAL_MODEL
- : adapterType === "opencode_local"
- ? model || DEFAULT_OPENCODE_LOCAL_MODEL
- : model,
- command,
- args,
- url,
- dangerouslySkipPermissions:
- adapterType === "claude_local" || adapterType === "opencode_local",
- dangerouslyBypassSandbox:
- adapterType === "codex_local"
- ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
- : defaultCreateValues.dangerouslyBypassSandbox,
- });
- if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) {
- const env =
- typeof config.env === "object" &&
- config.env !== null &&
- !Array.isArray(config.env)
- ? { ...(config.env as Record) }
- : {};
- env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
- config.env = env;
- }
- return config;
-}
diff --git a/ui/src/lib/onboarding-constants.ts b/ui/src/lib/onboarding-constants.ts
deleted file mode 100644
index 61b44a78645..00000000000
--- a/ui/src/lib/onboarding-constants.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-// Shared onboarding constants, extracted from OnboardingWizard so the new
-// presentational onboarding flow and the useOnboardingFlow hook share one
-// source of truth for storage keys, default task copy, and error messages.
-
-export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
-
-export const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
-
-export const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
-
-- hire a founding engineer
-- write a hiring plan
-- break the roadmap into concrete tasks and start delegating work`;
-
-export const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
- "Onboarding state is incomplete. Please restart onboarding and try again.";
diff --git a/ui/src/onboarding-preview-main.tsx b/ui/src/onboarding-preview-main.tsx
deleted file mode 100644
index f50add414da..00000000000
--- a/ui/src/onboarding-preview-main.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-// Standalone preview harness for the onboarding flows (no app shell / left nav),
-// modeled on the repo's thread-components harness.
-//
-// - `?flow=cloud` (default) renders CloudOnboardingFlow; `?flow=local` renders
-// LocalOnboardingFlow. Test both side-by-side in two tabs.
-// - The cloud flow walks the full arc (account → OTP → welcome …). The LOCAL
-// flow has no sign-in at all — it opens on its welcome screen, which carries
-// the orbiting-paperclip backdrop the auth screens would have shown.
-// - `?step=` deep-links straight to one screen. Cloud: account | otp |
-// start | company | agent | task. Local: start | email | company | agent |
-// adapter | task.
-//
-// SAFETY: `previewMock` makes every wired CTA skip its backend call, so the
-// flow is clickable end-to-end with no database writes. This harness is for
-// visual review / sharing only.
-import { StrictMode, useEffect, useRef, useState } from "react";
-import { createRoot } from "react-dom/client";
-import { MemoryRouter } from "@/lib/router";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { TooltipProvider } from "@/components/ui/tooltip";
-import { ThemeProvider } from "./context/ThemeContext";
-import { CompanyProvider } from "./context/CompanyContext";
-import { CloudOnboardingFlow } from "./components/onboarding/CloudOnboardingFlow";
-import { LocalOnboardingFlow } from "./components/onboarding/LocalOnboardingFlow";
-import { AccountScreen, OtpScreen } from "./components/onboarding/OnboardingAuthScreens";
-import {
- AUTH_EXIT_DURATION,
- OnboardingAuthBackdrop,
-} from "./components/onboarding/OnboardingAuthBackdrop";
-import "./index.css";
-
-// "exiting" holds the gap between the auth screens fading out and the flow
-// mounting, so the paperclip reaches 0% before the next screen appears.
-type Phase = "account" | "otp" | "exiting" | "flow";
-
-const params = new URLSearchParams(window.location.search);
-const isLocal = params.get("flow") === "local";
-const requestedStep = params.get("step");
-
-// Step names valid within the selected flow (drives deep-link vs. auth phase).
-const CLOUD_STEPS = ["start", "company", "agent", "task"] as const;
-const LOCAL_STEPS = ["start", "email", "company", "agent", "adapter", "task"] as const;
-const flowSteps: readonly string[] = isLocal ? LOCAL_STEPS : CLOUD_STEPS;
-const isFlowStep = requestedStep !== null && flowSteps.includes(requestedStep);
-
-const flowStart = isFlowStep ? requestedStep! : "start";
-// Which phase the harness opens on. The local flow skips sign-in entirely, so
-// it always opens on its welcome screen; cloud still walks account → OTP first.
-const initialPhase: Phase = isLocal
- ? "flow"
- : isFlowStep
- ? "flow"
- : requestedStep === "otp"
- ? "otp"
- : "account";
-
-function Preview() {
- const [phase, setPhase] = useState(initialPhase);
- const authVisible = phase === "account" || phase === "otp";
- const exitTimer = useRef(undefined);
-
- useEffect(() => () => window.clearTimeout(exitTimer.current), []);
-
- // Finishing sign-in fades the auth card + orbiting paperclip all the way out
- // FIRST, and only then mounts the flow — so the backdrop is fully gone before
- // the next screen appears rather than the two overlapping.
- function finishAuth() {
- setPhase("exiting");
- exitTimer.current = window.setTimeout(
- () => setPhase("flow"),
- AUTH_EXIT_DURATION * 1000,
- );
- }
-
- return (
- <>
- {phase === "flow" &&
- (isLocal ? (
- {}} />
- ) : (
- {}} />
- ))}
-
- {phase === "otp" ? (
-
- ) : (
- setPhase("otp")} />
- )}
-
- >
- );
-}
-
-const queryClient = new QueryClient({
- defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
-});
-
-createRoot(document.getElementById("root")!).render(
-
-
-
-
-
-
-
-
-
-
-
-
- ,
-);
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index 95298651658..d62334bef0f 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -120,15 +120,6 @@ import {
AvatarGroupCount,
} from "@/components/ui/avatar";
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
-import {
- Chip,
- ChoiceCard,
- ConnectorRow,
- OnboardingCard,
- OnboardingHeading,
- Stepper,
-} from "@/components/onboarding/OnboardingPrimitives";
-import { CONNECTORS, MISSION_CHIPS } from "@/components/onboarding/onboarding-data";
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { EnforcementBanner } from "@/components/EnforcementBanner";
@@ -262,61 +253,6 @@ function SubSection({ title, children }: { title: string; children: React.ReactN
);
}
-// Onboarding flow primitives (ported prototype): interactive demos need local
-// selection state, mirroring how OnboardingFlow drives them.
-function OnboardingChipsShowcase() {
- const [active, setActive] = useState(MISSION_CHIPS[0] ?? null);
- return (
-
- {MISSION_CHIPS.map((chip) => (
- setActive(chip)} />
- ))}
-
- );
-}
-
-function OnboardingChoiceShowcase() {
- const [choice, setChoice] = useState<"hiring" | "strategy">("hiring");
- return (
-
- }
- title="Create a hiring plan"
- description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
- selected={choice === "hiring"}
- onClick={() => setChoice("hiring")}
- />
- }
- title="Write a team strategy doc"
- description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
- selected={choice === "strategy"}
- onClick={() => setChoice("strategy")}
- />
-
- );
-}
-
-function OnboardingConnectorShowcase() {
- const [connected, setConnected] = useState([CONNECTORS[0]?.name ?? ""]);
- return (
-
- {CONNECTORS.slice(0, 2).map((c) => (
-
- setConnected((prev) =>
- prev.includes(c.name) ? prev.filter((n) => n !== c.name) : [...prev, c.name],
- )
- }
- />
- ))}
-
- );
-}
-
// Onboarding seam (design §6 + §12.5): the TeamCard tile in its "Pick a starter
// team" 3-col grid, with the first defaultInstall tile selected.
function TeamCardShowcase() {
@@ -496,7 +432,6 @@ export function DesignGuide() {
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip",
- "AgentCapsule", "OnboardingCard", "Stepper", "Chip", "ChoiceCard", "ConnectorRow",
].map((name) => (
{name}
@@ -831,38 +766,6 @@ export function DesignGuide() {
- {/* ============================================================ */}
- {/* ONBOARDING FLOW */}
- {/* ============================================================ */}
-
-
- Primitives for the full-screen onboarding flow (
- components/onboarding/). Bespoke surfaces keep
- their prototype dimensions via verbatim size tokens (
- --sz-560px card,{" "}
- --sz-320px start tiles); type, fields, and focus
- treatment come from the shared primitives and scale.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{/* ============================================================ */}
{/* FORM ELEMENTS */}
{/* ============================================================ */}
diff --git a/ui/storybook/stories/data-viz-misc.stories.tsx b/ui/storybook/stories/data-viz-misc.stories.tsx
index 62014557590..088ff4e717b 100644
--- a/ui/storybook/stories/data-viz-misc.stories.tsx
+++ b/ui/storybook/stories/data-viz-misc.stories.tsx
@@ -28,7 +28,7 @@ import { EntityRow } from "@/components/EntityRow";
import { FilterBar, type FilterValue } from "@/components/FilterBar";
import { KanbanBoard } from "@/components/KanbanBoard";
import { LiveRunWidget } from "@/components/LiveRunWidget";
-import { CloudOnboardingFlow } from "@/components/onboarding/CloudOnboardingFlow";
+import { OnboardingWizard } from "@/components/OnboardingWizard";
import {
buildFileTree,
collectAllPaths,
@@ -43,6 +43,7 @@ import { SwipeToArchive } from "@/components/SwipeToArchive";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { useDialog } from "@/context/DialogContext";
import { queryKeys } from "@/lib/queryKeys";
import {
createIssue,
@@ -323,9 +324,19 @@ function LiveRunWidgetStory({ empty = false, loading = false }: { empty?: boolea
);
}
-function OnboardingStepStory({ step }: { step: "company" | "agent" }) {
- // previewMock keeps the flow backend-free for the story canvas.
- return ;
+function OpenOnboardingOnMount({ initialStep }: { initialStep: 1 | 2 }) {
+ const { openOnboarding } = useDialog();
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ queryClient.setQueryData(queryKeys.agents.adapterModels(companyId, "claude_local"), [
+ { id: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
+ { id: "claude-opus-4-1", label: "Claude Opus 4.1" },
+ ]);
+ openOnboarding(initialStep === 1 ? { initialStep } : { initialStep, companyId });
+ }, [initialStep, openOnboarding, queryClient]);
+
+ return ;
}
function PackageFileTreeDemo({ empty = false }: { empty?: boolean }) {
@@ -694,14 +705,14 @@ export const LiveRunWidgetEmpty: Story = {
render: () => ,
};
-export const OnboardingFlowCompanyStep: Story = {
- name: "Onboarding Flow / Company Step",
- render: () => ,
+export const OnboardingWizardCompanyStep: Story = {
+ name: "OnboardingWizard / Company Step",
+ render: () => ,
};
-export const OnboardingFlowAgentStep: Story = {
- name: "Onboarding Flow / Agent Step",
- render: () => ,
+export const OnboardingWizardAgentStep: Story = {
+ name: "OnboardingWizard / Agent Step",
+ render: () => ,
};
export const PackageFileTreePopulated: Story = {
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 7ba2f25f834..d235797fec7 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -11,12 +11,6 @@ export default defineConfig(({ mode }) => ({
plugins: [react(), tailwindcss()],
build: {
minify: "esbuild",
- rollupOptions: {
- input: {
- main: path.resolve(__dirname, "index.html"),
- "onboarding-preview": path.resolve(__dirname, "onboarding-preview.html"),
- },
- },
},
esbuild:
mode === "production"
@@ -32,9 +26,7 @@ export default defineConfig(({ mode }) => ({
},
},
server: {
- // Harness-managed preview servers assign a port via the PORT env var;
- // default stays 5173 for plain `vite` runs.
- port: process.env.PORT ? Number(process.env.PORT) : 5173,
+ port: 5173,
watch: createUiDevWatchOptions(process.cwd()),
proxy: apiProxy,
},
From 677242344c7850257ca3e26a9eda849a1a704447 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 7 Aug 2026 16:52:45 -0700
Subject: [PATCH 010/201] chore(lockfile): refresh pnpm-lock.yaml (#11068)
Auto-generated lockfile refresh after dependencies changed on master.
This PR only updates pnpm-lock.yaml.
Co-authored-by: lockfile-bot
---
pnpm-lock.yaml | 113 -------------------------------------------------
1 file changed, 113 deletions(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24fa1c13e49..1f367d5bd43 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -972,9 +972,6 @@ importers:
mermaid:
specifier: ^11.16.0
version: 11.16.0
- motion:
- specifier: ^12.42.2
- version: 12.43.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
radix-ui:
specifier: ^1.6.4
version: 1.6.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -1002,9 +999,6 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
- three:
- specifier: ^0.185.1
- version: 0.185.1
devDependencies:
'@storybook/addon-a11y':
specifier: 10.5.4
@@ -1027,9 +1021,6 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.3(@types/react@19.2.17)
- '@types/three':
- specifier: ^0.185.1
- version: 0.185.4
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.7.0(vite@6.4.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))
@@ -1791,9 +1782,6 @@ packages:
resolution: {integrity: sha512-pHoVRSiyBMTFBMjhAihvzXbiJSFNs63dULE+11jzCX/s1a7/I7Zp0cC4jWRw1ykckEPcho8QiEEgSR3qBRmDBw==}
engines: {node: '>=22.13'}
- '@dimforge/rapier3d-compat@0.12.0':
- resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
-
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -5101,9 +5089,6 @@ packages:
resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==}
engines: {node: '>= 6'}
- '@tweenjs/tween.js@23.1.3':
- resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
-
'@tybys/wasm-util@0.10.3':
resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==}
@@ -5311,18 +5296,12 @@ packages:
resolution: {integrity: sha512-OOi3kL+FZDnPhVzsfD37J88FNeZh6gQsGcLc95NbeURRGvmSjeXiDcyWzF2o3yh/gQAUn2uhh/e+CPCa5nwAxw==}
deprecated: This is a stub types definition. sharp provides its own type definitions, so you do not need this installed.
- '@types/stats.js@0.17.4':
- resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
-
'@types/superagent@8.1.9':
resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==}
'@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
- '@types/three@0.185.4':
- resolution: {integrity: sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==}
-
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -5335,9 +5314,6 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
- '@types/webxr@0.5.24':
- resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
-
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -6591,9 +6567,6 @@ packages:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
- fflate@0.8.3:
- resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
-
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
@@ -6625,20 +6598,6 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
- framer-motion@12.43.0:
- resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==}
- peerDependencies:
- '@emotion/is-prop-valid': '*'
- react: ^19.2.7
- react-dom: ^19.2.7
- peerDependenciesMeta:
- '@emotion/is-prop-valid':
- optional: true
- react:
- optional: true
- react-dom:
- optional: true
-
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -7273,9 +7232,6 @@ packages:
mermaid@11.16.0:
resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==}
- meshoptimizer@1.1.1:
- resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
-
methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
@@ -7481,26 +7437,6 @@ packages:
engines: {node: '>=10'}
hasBin: true
- motion-dom@12.43.0:
- resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==}
-
- motion-utils@12.39.0:
- resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
-
- motion@12.43.0:
- resolution: {integrity: sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==}
- peerDependencies:
- '@emotion/is-prop-valid': '*'
- react: ^19.2.7
- react-dom: ^19.2.7
- peerDependenciesMeta:
- '@emotion/is-prop-valid':
- optional: true
- react:
- optional: true
- react-dom:
- optional: true
-
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -8419,9 +8355,6 @@ packages:
thread-stream@3.1.0:
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
- three@0.185.1:
- resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==}
-
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -10018,8 +9951,6 @@ snapshots:
'@cursor/sdk-linux-x64': 1.0.24
'@cursor/sdk-win32-x64': 1.0.24
- '@dimforge/rapier3d-compat@0.12.0': {}
-
'@dnd-kit/accessibility@3.1.1(react@19.2.7)':
dependencies:
react: 19.2.7
@@ -12972,8 +12903,6 @@ snapshots:
'@tootallnate/once@1.1.2':
optional: true
- '@tweenjs/tween.js@23.1.3': {}
-
'@tybys/wasm-util@0.10.3':
dependencies:
tslib: 2.8.1
@@ -13231,8 +13160,6 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
- '@types/stats.js@0.17.4': {}
-
'@types/superagent@8.1.9':
dependencies:
'@types/cookiejar': 2.1.5
@@ -13245,15 +13172,6 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.9
- '@types/three@0.185.4':
- dependencies:
- '@dimforge/rapier3d-compat': 0.12.0
- '@tweenjs/tween.js': 23.1.3
- '@types/stats.js': 0.17.4
- '@types/webxr': 0.5.24
- fflate: 0.8.3
- meshoptimizer: 1.1.1
-
'@types/tough-cookie@4.0.5': {}
'@types/trusted-types@2.0.7': {}
@@ -13262,8 +13180,6 @@ snapshots:
'@types/unist@3.0.3': {}
- '@types/webxr@0.5.24': {}
-
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.20.1
@@ -14559,8 +14475,6 @@ snapshots:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
- fflate@0.8.3: {}
-
file-uri-to-path@1.0.0:
optional: true
@@ -14602,15 +14516,6 @@ snapshots:
forwarded@0.2.0: {}
- framer-motion@12.43.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
- dependencies:
- motion-dom: 12.43.0
- motion-utils: 12.39.0
- tslib: 2.8.1
- optionalDependencies:
- react: 19.2.7
- react-dom: 19.2.7(react@19.2.7)
-
fresh@2.0.0: {}
fs-constants@1.0.0:
@@ -15447,8 +15352,6 @@ snapshots:
ts-dedent: 2.3.0
uuid: 14.0.1
- meshoptimizer@1.1.1: {}
-
methods@1.1.2: {}
micromark-core-commonmark@2.0.3:
@@ -15826,20 +15729,6 @@ snapshots:
mkdirp@1.0.4:
optional: true
- motion-dom@12.43.0:
- dependencies:
- motion-utils: 12.39.0
-
- motion-utils@12.39.0: {}
-
- motion@12.43.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
- dependencies:
- framer-motion: 12.43.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
- tslib: 2.8.1
- optionalDependencies:
- react: 19.2.7
- react-dom: 19.2.7(react@19.2.7)
-
mri@1.2.0: {}
ms@2.1.3: {}
@@ -17079,8 +16968,6 @@ snapshots:
dependencies:
real-require: 0.2.0
- three@0.185.1: {}
-
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
From d5208d30c17720f8628be2b5fb88c012b5cab91c Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Fri, 7 Aug 2026 17:47:30 -0700
Subject: [PATCH 011/201] feat(adapter-utils): add host-side pack span to
managed-runtime tarball build (#11072)
## Thinking Path
> - Paperclip runs AI agents through adapter execution services.
> - The adapter runtime records spans for each stage of agent startup.
> - The managed runtime packs workspace tarballs before it uploads them.
> - These pack operations had no host span, so `stage.sync` omitted pack
time.
> - This pull request adds one `pack` span around both tarball builds.
> - The span nests under the active `stage.sync` step and improves trace
detail.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
The managed runtime workspace sync builds a git-history tarball and a
workspace-overlay tarball before upload.
**Subsystem affected**
The change affects `packages/adapter-utils`, which provides adapter
execution and managed runtime support.
**Current behavior**
The host builds both tarballs without an OpenTelemetry span. The
`stage.sync` trace therefore omits the host pack duration.
**Proposed behavior**
The host wraps both tarball builds in one `pack` span. The executor
parents this span under the active startup step.
**Reason and benefit**
The trace shows the time that the host spends packing workspace data.
Operators can use the existing runtime span tree to find sync delays.
**Breaking changes**
None. The default span runner remains a no-op runner, and the existing
control flow remains unchanged.
## What Changed
- Add an optional `runtimeSpan` runner to the managed runtime
preparation path.
- Create one host `pack` span around the two workspace tarball builds.
- Parent the `pack` span under the active startup step.
- Add unit coverage for span emission and span nesting.
## Verification
- Run `tsc --noEmit` for `@paperclipai/adapter-utils`.
- Run the `@paperclipai/adapter-utils` Vitest suite.
- Confirm the suite reports 448 passed tests and 4 skipped tests.
- Confirm the trace test records `pack` under `stage.sync`.
## Risks
The change adds optional tracing only. The default no-op runner
preserves behavior when tracing is not configured.
## Model Used
OpenAI Codex, GPT-5, tool use and code execution. The model reviewed the
handoff and opened this pull request. The implementation author supplied
the commit.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
.../src/acpx-engine/execute.test.ts | 18 +-
.../adapter-utils/src/acpx-engine/execute.ts | 26 ++-
.../src/command-managed-runtime.ts | 7 +
.../adapter-utils/src/execution-target.ts | 6 +
.../src/sandbox-managed-runtime.test.ts | 192 ++++++++++++++++++
.../src/sandbox-managed-runtime.ts | 136 +++++++------
6 files changed, 323 insertions(+), 62 deletions(-)
diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts
index dfd16318bae..242ca779957 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.test.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts
@@ -3272,7 +3272,8 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
// A codex bring-up over the remote sandbox lane crosses all 7 boundaries.
// Each boundary span parents to the sandbox bring-up span, not to the run
- // root or the turn span.
+ // root or the turn span. The `stage.sync` step also opens one host `pack`
+ // span around the workspace tarball build, so it nests one level deeper.
const childNames = spans
.filter((span) => span !== runRootSpan && span !== startupSpan && span !== turnSpan)
.map((span) => span.name)
@@ -3283,15 +3284,28 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () =
"bridge.paperclip",
"bridge.process-session",
"codex-home.seed",
+ "pack",
"skills.reconcile",
"stage.sync",
"workspace.resolve",
],
);
- // Every boundary span parents to the sandbox bring-up span and ends.
+ // The host `pack` span nests under the `stage.sync` step span (the host
+ // tarball build runs inside that step), not directly under the bring-up
+ // span.
+ const stageSyncSpan = spans.find((span) => span.name === "stage.sync");
+ const packSpan = spans.find((span) => span.name === "pack");
+ expect(stageSyncSpan).toBeTruthy();
+ expect(packSpan).toBeTruthy();
+ expect(packSpan!.parent).toBe(stageSyncSpan);
+ expect(packSpan!.ended).toBe(true);
+
+ // Every boundary step span parents to the sandbox bring-up span and ends.
+ // The `pack` span is the one exception: it parents to `stage.sync` above.
for (const span of spans) {
if (span === runRootSpan || span === startupSpan || span === turnSpan) continue;
+ if (span === packSpan) continue;
expect(span.parent, `span "${span.name}" must parent to the startup span`).toBe(startupSpan);
expect(span.ended, `span "${span.name}" must end`).toBe(true);
}
diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts
index 9a5a613a63c..3dc9fa7260b 100644
--- a/packages/adapter-utils/src/acpx-engine/execute.ts
+++ b/packages/adapter-utils/src/acpx-engine/execute.ts
@@ -86,6 +86,7 @@ import {
import {
createRuntimeSpanRunner,
emitSkippedStartupStep,
+ getActiveStepContext,
measureStartupStep,
NOOP_STARTUP_SPAN,
NOOP_STARTUP_TRACE_CONTEXT,
@@ -1332,6 +1333,11 @@ async function stageAcpRemoteRuntime(input: {
additionalSources?: SandboxAdditionalSource[];
onLog: AdapterExecutionContext["onLog"];
onRuntimeProgress: AdapterExecutionContext["onRuntimeProgress"];
+ // Optional host span runner for the workspace tarball build. It rides down to
+ // prepareSandboxManagedRuntime so the host pack time shows as one `pack` span.
+ // The caller passes a runner that parents to the active `stage.sync` step, so
+ // the `pack` span nests under `stage.sync`. The default is a no-op.
+ runtimeSpan?: RuntimeSpanRunner;
}): Promise {
await input.onLog(
"stdout",
@@ -1350,6 +1356,7 @@ async function stageAcpRemoteRuntime(input: {
: {}),
onProgress: (line) => input.onLog("stdout", line),
onRuntimeProgress: input.onRuntimeProgress,
+ runtimeSpan: input.runtimeSpan,
});
}
@@ -1375,6 +1382,12 @@ async function buildRuntime(input: {
// span per unit of work. The run closure passes the run-scoped runner here;
// when it is absent, each bridge site opens no wrapper span.
runtimeSpan?: RuntimeSpanRunner;
+ // Wrap the host workspace tarball build in one `pack` span. Unlike
+ // `runtimeSpan`, this runner parents each span to the active startup step, so
+ // the `pack` span nests under the `stage.sync` step that runs the staging
+ // seam. `buildRuntime` threads it into the staging seam. When it is absent, the
+ // staging seam opens no `pack` span.
+ stageRuntimeSpan?: RuntimeSpanRunner;
}): Promise {
const { runId, agent, config, context, authToken } = input.ctx;
// Injectable monotonic clock for per-step startup timing. Hoisted above the
@@ -1865,6 +1878,7 @@ async function buildRuntime(input: {
additionalSources,
onLog: input.ctx.onLog,
onRuntimeProgress: input.ctx.onRuntimeProgress,
+ runtimeSpan: input.stageRuntimeSpan,
});
// Snapshot env before the seam so we can capture exactly which keys it
// repointed onto the in-sandbox home (e.g. `CODEX_HOME`) and replay them
@@ -3155,6 +3169,16 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
// `getRuntimeParentContext`, so a wrapper span always parents to the current
// run span. On a no-op trace context the runner opens no real span.
const runRuntimeSpan = createRuntimeSpanRunner(tracing, getRuntimeParentContext);
+ // Wrap the host workspace tarball build in one `pack` span. This runner
+ // parents each span to the ACTIVE startup step (not the run span), so the
+ // `pack` span nests under the `stage.sync` step that runs the staging seam.
+ // The staging seam runs inside `stage.sync`'s measured step, so
+ // `getActiveStepContext()` returns that step's child context at pack time.
+ // On a no-op trace context the runner opens no real span.
+ const runStageSpan = createRuntimeSpanRunner(
+ tracing,
+ () => getActiveStepContext()?.parentContext,
+ );
// `runFailed` marks the run root span status at end time. It stays `true`
// until the run reaches a clean completed turn, so every failure and every
// early exit closes the span with error status.
@@ -3214,7 +3238,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
// parents to its step span. On a local or SSH target
// `spanParent.parentContext` is a no-op token, so the wrap is inert.
prepared = await runWithRuntimeParent(spanParent.parentContext, () =>
- buildRuntime({ ctx, engine, deps, spanParent, getRuntimeParentContext, runtimeSpan: runRuntimeSpan }),
+ buildRuntime({ ctx, engine, deps, spanParent, getRuntimeParentContext, runtimeSpan: runRuntimeSpan, stageRuntimeSpan: runStageSpan }),
);
} catch (err) {
rootSpan.end(true);
diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts
index 5688b4f194b..32e459a21fb 100644
--- a/packages/adapter-utils/src/command-managed-runtime.ts
+++ b/packages/adapter-utils/src/command-managed-runtime.ts
@@ -16,6 +16,7 @@ import {
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
import type { RunProcessResult } from "./server-utils.js";
import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js";
+import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js";
export interface CommandManagedRuntimeRunner {
/**
@@ -478,6 +479,10 @@ export async function prepareCommandManagedRuntime(input: {
// task wires it into the byte-counting writeFile/readFile transport.
onProgress?: RuntimeProgressSink;
onRuntimeProgress?: RuntimeStatusSink;
+ // Optional host span runner for the workspace tarball build. Forwarded to
+ // prepareSandboxManagedRuntime so the host pack time rides one `pack` span
+ // under the `stage.sync` step. The default is a no-op.
+ runtimeSpan?: RuntimeSpanRunner;
}): Promise {
const timeoutMs = input.spec.timeoutMs && input.spec.timeoutMs > 0 ? input.spec.timeoutMs : 300_000;
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
@@ -529,6 +534,7 @@ export async function prepareCommandManagedRuntime(input: {
additionalSources: input.additionalSources,
onProgress: input.onProgress,
onRuntimeProgress: input.onRuntimeProgress,
+ runtimeSpan: input.runtimeSpan,
});
}
}
@@ -567,5 +573,6 @@ export async function prepareCommandManagedRuntime(input: {
additionalSources: input.additionalSources,
onProgress: input.onProgress,
onRuntimeProgress: input.onRuntimeProgress,
+ runtimeSpan: input.runtimeSpan,
});
}
diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts
index 90989c6f9f8..a525b2f2900 100644
--- a/packages/adapter-utils/src/execution-target.ts
+++ b/packages/adapter-utils/src/execution-target.ts
@@ -1150,6 +1150,11 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
// counters without further changes here.
onProgress?: RuntimeProgressSink;
onRuntimeProgress?: RuntimeStatusSink;
+ // Optional host span runner for the workspace tarball build. Only the confined
+ // sandbox lane uses it: it forwards the runner to prepareCommandManagedRuntime
+ // so the host pack time rides one `pack` span under the `stage.sync` step. The
+ // SSH and local lanes ignore it. The default is a no-op.
+ runtimeSpan?: RuntimeSpanRunner;
}): Promise {
const target = input.target ?? { kind: "local" as const };
if (target.kind === "local") {
@@ -1213,6 +1218,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: {
detectCommand: input.detectCommand,
onProgress: input.onProgress,
onRuntimeProgress: input.onRuntimeProgress,
+ runtimeSpan: input.runtimeSpan,
});
return {
target,
diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts
index 4e82cb0d86c..52c3cb65db0 100644
--- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts
+++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts
@@ -19,6 +19,15 @@ import {
prepareCommandManagedRuntime,
type CommandManagedRuntimeRunner,
} from "./command-managed-runtime.js";
+import {
+ createRuntimeSpanRunner,
+ getActiveStepContext,
+ measureStartupStep,
+ type RuntimeSpanRunner,
+ type StartupSpan,
+ type StartupTraceContext,
+ type StartupTracer,
+} from "./acpx-engine/startup-timing.js";
import type { RunProcessResult } from "./server-utils.js";
function toArrayBuffer(bytes: Buffer): ArrayBuffer {
@@ -156,6 +165,87 @@ async function listTarMembers(rootDir: string, name: string, bytes: Buffer): Pro
return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
}
+// Build a filesystem-backed managed-runtime client. The host tarball path runs
+// unchanged; the client just materializes the mappings on the local disk, so a
+// pack-span test needs no provider.
+function makeFilesystemClient(): SandboxManagedRuntimeClient {
+ const client: SandboxManagedRuntimeClient = {
+ makeDir: async (remotePath) => {
+ await mkdir(remotePath, { recursive: true });
+ },
+ writeFile: async (remotePath, bytes) => {
+ await mkdir(path.dirname(remotePath), { recursive: true });
+ await writeFile(remotePath, Buffer.from(bytes));
+ },
+ readFile: async (remotePath) => await readFile(remotePath),
+ listFiles: async (remotePath) => {
+ const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []);
+ return entries
+ .filter((entry) => entry.isFile())
+ .map((entry) => entry.name)
+ .sort((left, right) => left.localeCompare(right));
+ },
+ remove: async (remotePath) => {
+ await rm(remotePath, { recursive: true, force: true });
+ },
+ run: async (command) => {
+ await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
+ },
+ };
+ attachFallbackSyncIn(client);
+ return client;
+}
+
+// One recorded span from the fake tracer. `parentName` is the name of the span
+// that the start context carried, so a test can assert the parent relationship.
+interface RecordedSpan {
+ name: string;
+ parentName: string | null;
+ ended: boolean;
+ attributes: Record;
+}
+
+// A fake trace context that records every span and its parent by name. It
+// satisfies the structural `StartupTraceContext` contract, so the real
+// `createRuntimeSpanRunner` and `measureStartupStep` drive it unchanged. The
+// opaque parent token is the parent's `RecordedSpan`, so a child span reads its
+// parent name from the start context.
+function createRecordingTraceContext(): {
+ traceContext: StartupTraceContext;
+ spans: RecordedSpan[];
+} {
+ const spans: RecordedSpan[] = [];
+ const byHandle = new WeakMap();
+ const tracer: StartupTracer = {
+ startSpan(name, options, context) {
+ const parent = context as RecordedSpan | undefined;
+ const record: RecordedSpan = {
+ name,
+ parentName: parent?.name ?? null,
+ ended: false,
+ attributes: { ...(options?.attributes ?? {}) },
+ };
+ spans.push(record);
+ const handle: StartupSpan = {
+ setAttribute(key, value) {
+ record.attributes[key] = value;
+ },
+ setStatus() {},
+ end() {
+ record.ended = true;
+ },
+ };
+ byHandle.set(handle, record);
+ return handle;
+ },
+ };
+ const traceContext: StartupTraceContext = {
+ tracer,
+ contextWithSpan: (span) => byHandle.get(span),
+ };
+ return { traceContext, spans };
+}
+
describe("sandbox managed runtime", () => {
const cleanupDirs: string[] = [];
@@ -1929,4 +2019,106 @@ describe("sandbox managed runtime", () => {
else process.env[flagKey] = priorFlag;
}
});
+
+ it("builds the workspace tarball inside one host pack span for a usual workspace sync", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-span-"));
+ cleanupDirs.push(rootDir);
+ const localWorkspaceDir = path.join(rootDir, "local-workspace");
+ const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
+ await mkdir(localWorkspaceDir, { recursive: true });
+ await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace body\n", "utf8");
+
+ // Record every span name the runner opens and run the wrapped work, so the
+ // test proves the host opens exactly one `pack` span around the tarball
+ // build for the usual (plain) workspace sync.
+ const openedSpans: string[] = [];
+ const runtimeSpan: RuntimeSpanRunner = async (name, work) => {
+ openedSpans.push(name);
+ return await work();
+ };
+
+ const prepared = await prepareSandboxManagedRuntime({
+ spec: {
+ transport: "sandbox",
+ provider: "test",
+ sandboxId: "sandbox-pack",
+ remoteCwd: remoteWorkspaceDir,
+ timeoutMs: 30_000,
+ apiKey: null,
+ },
+ adapterKey: "test-adapter",
+ client: makeFilesystemClient(),
+ workspaceLocalDir: localWorkspaceDir,
+ runtimeSpan,
+ });
+
+ expect(openedSpans).toEqual(["pack"]);
+ // The tarball build still lands the workspace inside the span, so the wrap
+ // changes no staging behavior.
+ await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("workspace body\n");
+ expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir);
+ });
+
+ it("nests the host pack span under the stage.sync step span", async () => {
+ const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-nest-"));
+ cleanupDirs.push(rootDir);
+ const localWorkspaceDir = path.join(rootDir, "local-workspace");
+ const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
+ await mkdir(localWorkspaceDir, { recursive: true });
+ await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace body\n", "utf8");
+
+ const { traceContext, spans } = createRecordingTraceContext();
+ // The root span stands in for `sandbox.startup`. Its child context is the
+ // step span's parent, exactly as the executor wires it.
+ const rootHandle = traceContext.tracer.startSpan("sandbox.startup", undefined, undefined);
+ const rootContext = traceContext.contextWithSpan(rootHandle);
+
+ // The stage runner parents each span to the ACTIVE startup step, so the
+ // `pack` span nests under `stage.sync`. This is the exact runner the
+ // executor threads into the staging seam.
+ const stageRuntimeSpan = createRuntimeSpanRunner(
+ traceContext,
+ () => getActiveStepContext()?.parentContext,
+ );
+
+ // A deterministic monotonic clock, so the step timing stays test-stable.
+ let clock = 0;
+ const now = () => (clock += 1000);
+
+ await measureStartupStep(
+ {},
+ now,
+ "stage.sync",
+ async () => {
+ await prepareSandboxManagedRuntime({
+ spec: {
+ transport: "sandbox",
+ provider: "test",
+ sandboxId: "sandbox-nest",
+ remoteCwd: remoteWorkspaceDir,
+ timeoutMs: 30_000,
+ apiKey: null,
+ },
+ adapterKey: "test-adapter",
+ client: makeFilesystemClient(),
+ workspaceLocalDir: localWorkspaceDir,
+ runtimeSpan: stageRuntimeSpan,
+ });
+ },
+ {
+ tracer: traceContext.tracer,
+ parentContext: rootContext,
+ contextWithSpan: (span) => traceContext.contextWithSpan(span),
+ },
+ );
+
+ const stageSpan = spans.find((span) => span.name === "stage.sync");
+ const packSpan = spans.find((span) => span.name === "pack");
+ expect(stageSpan).toBeDefined();
+ expect(packSpan).toBeDefined();
+ expect(packSpan!.ended).toBe(true);
+ // The `pack` span parents to `stage.sync`, not to the root span, so it nests
+ // under the step in a real trace.
+ expect(packSpan!.parentName).toBe("stage.sync");
+ });
});
diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts
index 2f9db88dc8f..4169ced4a77 100644
--- a/packages/adapter-utils/src/sandbox-managed-runtime.ts
+++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts
@@ -27,6 +27,7 @@ import {
type RuntimeStatusSink,
} from "./runtime-progress.js";
import { isRelativePathOrDescendant, shouldExcludePath } from "./exclude-patterns.js";
+import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js";
const execFile = promisify(execFileCallback);
const SANDBOX_WORKSPACE_HEAVY_DIR_NAMES = [
@@ -703,6 +704,12 @@ export async function prepareSandboxManagedRuntime(input: {
// child task wires it into writeFile/readFile.
onProgress?: RuntimeProgressSink;
onRuntimeProgress?: RuntimeStatusSink;
+ // Optional host span runner for the workspace tarball build. When present, the
+ // host builds both workspace tarballs inside one span named `pack`, so the
+ // host pack time is visible under the `stage.sync` step. The default is a
+ // no-op that keeps the current behavior and control flow. A throwing runner
+ // never changes control flow (see `createRuntimeSpanRunner`).
+ runtimeSpan?: RuntimeSpanRunner;
}): Promise {
const workspaceRemoteDir = input.workspaceRemoteDir ?? input.spec.remoteCwd;
const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey);
@@ -835,73 +842,84 @@ export async function prepareSandboxManagedRuntime(input: {
const workspacePostUploadCommands: SandboxPostUploadCommand[] = [];
let workspaceUploadBytes = 0;
- // 1. git-history tar (git-backed workspace only). Both tar targets live under
- // `runtimeRootDir` (`.paperclip-runtime/`). The git extract
- // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,
- // which sits under `.paperclip-runtime`, survives to run its own extract.
- if (gitSnapshot) {
- await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox");
- const gitTarPath = path.join(tempDir, "git-workspace.tar");
- const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar");
- await withShallowGitWorkspaceClone({
- localDir: input.workspaceLocalDir,
- snapshot: gitSnapshot,
- }, async (cloneDir) => {
- await createTarballFromDirectory({
- localDir: cloneDir,
- archivePath: gitTarPath,
- exclude: [".paperclip-runtime"],
+ // Build both host tarballs (the git-history tar and the workspace-overlay
+ // tar) inside one host span named `pack`. This span makes the host pack
+ // time visible under the `stage.sync` step, where it is otherwise a hidden
+ // gap with no span. The transfer (`stageConfinedSyncIn`) runs after this
+ // span, so `pack` measures only the host tar-build cost. The runner
+ // defaults to a no-op, so a caller with no injected runner keeps the
+ // current behavior and control flow.
+ const runPackSpan = (work: () => Promise): Promise =>
+ input.runtimeSpan ? input.runtimeSpan("pack", work) : work();
+ await runPackSpan(async () => {
+ // 1. git-history tar (git-backed workspace only). Both tar targets live under
+ // `runtimeRootDir` (`.paperclip-runtime/`). The git extract
+ // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar,
+ // which sits under `.paperclip-runtime`, survives to run its own extract.
+ if (gitSnapshot) {
+ await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox");
+ const gitTarPath = path.join(tempDir, "git-workspace.tar");
+ const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar");
+ await withShallowGitWorkspaceClone({
+ localDir: input.workspaceLocalDir,
+ snapshot: gitSnapshot,
+ }, async (cloneDir) => {
+ await createTarballFromDirectory({
+ localDir: cloneDir,
+ archivePath: gitTarPath,
+ exclude: [".paperclip-runtime"],
+ });
});
+ workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
+ workspacePostUploadCommands.push({
+ command: buildWorkspaceTarExtractCommand({
+ workspaceRemoteDir,
+ remoteTar: remoteGitTar,
+ wipeExceptNames: [".paperclip-runtime"],
+ }),
+ });
+ workspaceUploadBytes += (await fs.stat(gitTarPath)).size;
+ }
+
+ // 2. workspace-overlay tar. A git-backed overlay merges on top of the just
+ // extracted git tree (no wipe); a plain workspace wipes every child except
+ // the preserved names first. The extract runs AFTER the git extract.
+ await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox");
+ const workspaceTarPath = path.join(tempDir, "workspace.tar");
+ const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir;
+ if (gitSnapshot) {
+ await copySelectedWorkspaceEntries({
+ sourceDir: input.workspaceLocalDir,
+ targetDir: workspaceArchiveDir,
+ relativePaths: gitSnapshot.overlayPaths,
+ exclude: workspaceArchiveExclude,
+ });
+ }
+ await createTarballFromDirectory({
+ localDir: workspaceArchiveDir,
+ archivePath: workspaceTarPath,
+ exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
});
- workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
+ const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
+ workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
workspacePostUploadCommands.push({
command: buildWorkspaceTarExtractCommand({
workspaceRemoteDir,
- remoteTar: remoteGitTar,
- wipeExceptNames: [".paperclip-runtime"],
+ remoteTar: remoteWorkspaceTar,
+ wipeExceptNames: gitSnapshot ? null : [...preservedNames],
}),
});
- workspaceUploadBytes += (await fs.stat(gitTarPath)).size;
- }
-
- // 2. workspace-overlay tar. A git-backed overlay merges on top of the just
- // extracted git tree (no wipe); a plain workspace wipes every child except
- // the preserved names first. The extract runs AFTER the git extract.
- await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox");
- const workspaceTarPath = path.join(tempDir, "workspace.tar");
- const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir;
- if (gitSnapshot) {
- await copySelectedWorkspaceEntries({
- sourceDir: input.workspaceLocalDir,
- targetDir: workspaceArchiveDir,
- relativePaths: gitSnapshot.overlayPaths,
- exclude: workspaceArchiveExclude,
- });
- }
- await createTarballFromDirectory({
- localDir: workspaceArchiveDir,
- archivePath: workspaceTarPath,
- exclude: gitSnapshot ? undefined : workspaceArchiveExclude,
+ // 3. Optional remove-deleted-paths command runs LAST, after both extracts.
+ if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) {
+ workspacePostUploadCommands.push({
+ command: buildRemoveDeletedPathsCommand({
+ remoteDir: workspaceRemoteDir,
+ deletedPaths: gitSnapshot.deletedPaths,
+ }),
+ });
+ }
+ workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size;
});
- const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar");
- workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file", access: "rw", writablePath: workspaceRemoteDir });
- workspacePostUploadCommands.push({
- command: buildWorkspaceTarExtractCommand({
- workspaceRemoteDir,
- remoteTar: remoteWorkspaceTar,
- wipeExceptNames: gitSnapshot ? null : [...preservedNames],
- }),
- });
- // 3. Optional remove-deleted-paths command runs LAST, after both extracts.
- if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) {
- workspacePostUploadCommands.push({
- command: buildRemoveDeletedPathsCommand({
- remoteDir: workspaceRemoteDir,
- deletedPaths: gitSnapshot.deletedPaths,
- }),
- });
- }
- workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size;
// One confined `syncIn` for the whole merged workspace file set. The confine
// guard covers every mapping BEFORE any bytes upload (fail-closed): a source
From 1791979512d8f6e0e8c0f406f10e64b6f80e3756 Mon Sep 17 00:00:00 2001
From: Evyatar Bluzer
Date: Sat, 8 Aug 2026 07:51:03 +0700
Subject: [PATCH 012/201] refactor(a11y): add ARIA progressbar attributes to
BudgetPolicyCard (#1805)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Humans oversee those agents in teams, so each team needs its own
spend controls
> - The BudgetPolicyCard component shows how much of a budget is
consumed
> - The utilization bar in that card is a styled div with no ARIA role,
value, or label
> - A screen reader user therefore cannot hear how much budget is used
> - This pull request adds role="progressbar" and the matching ARIA
value attributes
> - The benefit is that assistive technology announces budget
utilization the same way sighted users see it
## Linked Issues or Issue Description
No existing issue. Related pull requests: #1869 and #1878 add the same
progressbar
semantics to ProviderQuotaCard and QuotaBar. They touch different files.
The problem follows the bug report template:
**What happened?**
The budget utilization bar in `BudgetPolicyCard` renders as a plain
`div`. It has no
`role`, no `aria-valuenow`, and no accessible name. A screen reader
announces nothing
for it. The user can read the "Remaining" amount, but not the
utilization percentage.
**Expected behavior**
The bar is announced as a progress bar. It reports the current
utilization percentage,
with its minimum and maximum.
**Steps to reproduce**
1. Open a project or agent page that shows the budget card.
2. Start VoiceOver (Cmd+F5 on macOS).
3. Move the cursor to the budget utilization bar.
4. VoiceOver announces nothing.
**Paperclip version or commit**
master, `ui/src/components/BudgetPolicyCard.tsx`.
**Deployment mode**
Local dev (pnpm dev).
## What Changed
- Added `role="progressbar"` to the inner bar element.
- Added `aria-valuenow` with the rounded utilization percentage.
- Added `aria-valuemin={0}` and `aria-valuemax={100}`.
- Added `aria-label` in the form `Budget utilization: 73% used`.
`aria-valuenow` and `aria-label` use the same `progress` value. That
value is already
capped at 100 by `Math.min(100, summary.utilizationPercent)`, so the
reported value
stays inside the min/max range when a scope is over budget.
An earlier revision also changed the budget amount `Input` to
`type="number"`. That
change is removed. It changed input behavior and was not an
accessibility fix.
See "Risks".
## Verification
1. Open a page that shows the budget card.
2. Start VoiceOver (Cmd+F5 on macOS) and move to the utilization bar.
3. VoiceOver announces "Budget utilization: X% used, progress
indicator".
4. Inspect the element. `aria-valuenow` equals the displayed percentage,
and it stays
at 100 when utilization is above 100%.
The change adds attributes only. There is no visual change.
## Risks
Low risk. The change adds ARIA attributes to one element in one
component. It changes
no logic, no styling, and no layout.
The `type="number"` change is removed on purpose. With `type="number"`,
a browser
reports an empty string for text it cannot parse. `parseDollarInput("")`
then returns
`0` instead of `null`, so the existing "Enter a valid non-negative
dollar amount."
error never appears and unparseable input silently becomes a $0.00
budget. The
`inputMode="decimal"` input keeps that validation path working.
## Model Used
Original implementation by @bluzername. The author did not state a
model.
Scope reduction, branch update, and this description: Claude Opus 5 (1M
context,
extended thinking, tool use), prepared under maintainer review.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change
- [x] I have considered and documented any risks above
- [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [ ] I have run tests locally and they pass
- [ ] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Andrew Aymeloglu
---
ui/src/components/BudgetPolicyCard.tsx | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/ui/src/components/BudgetPolicyCard.tsx b/ui/src/components/BudgetPolicyCard.tsx
index d392ff0c0e2..2b0624ac190 100644
--- a/ui/src/components/BudgetPolicyCard.tsx
+++ b/ui/src/components/BudgetPolicyCard.tsx
@@ -101,6 +101,11 @@ export function BudgetPolicyCard({
Date: Fri, 7 Aug 2026 18:30:16 -0700
Subject: [PATCH 013/201] fix(server): ignore sibling worktrees in dev watch
(#11074)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Developers can run Paperclip from linked Git worktrees.
> - The server development watcher scans paths near the active checkout.
> - A main checkout can contain many complete sibling worktrees under
`.paperclip/worktrees`.
> - Scanning those sibling checkouts can stall the watcher before it
starts the server.
> - This pull request excludes the shared worktree directory from the
development watcher.
> - The benefit is that development startup stays responsive as the
number of worktrees grows.
## Linked Issues or Issue Description
**What happened?**
The server development watcher traversed sibling checkouts under
`.paperclip/worktrees`. Large worktree collections could make `pnpm dev`
stall before the watcher started the server process.
**Expected behavior**
The watcher must observe only source paths that can reload the active
checkout. It must ignore sibling worktrees in both a main checkout and a
linked worktree.
**Steps to reproduce**
1. Create several linked worktrees under `.paperclip/worktrees`.
2. Add normal dependency and build output trees to those worktrees.
3. Run `pnpm dev` from the main checkout or one linked worktree.
4. Observe the watcher scan sibling worktrees before it starts the
server.
**Paperclip version or commit**
Reproduced on `master` before this change.
**Deployment mode**
Local development with `pnpm dev`.
## What Changed
- Detect whether the active server root is inside the managed
linked-worktree directory.
- Ignore the shared `.paperclip/worktrees` root from both main and
linked checkouts.
- Add regression coverage for the resolved ignore path and its globstar
form.
## Verification
- `./node_modules/.bin/vitest run
server/src/__tests__/dev-watch-ignore.test.ts --reporter=verbose`
- `pnpm --filter @paperclipai/server typecheck`
## Risks
- Low risk. The change affects only local development watch exclusions.
- A non-standard checkout that copies the same `.paperclip/worktrees`
directory layout will receive the same exclusion.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, GPT-5, context window not disclosed, with reasoning,
tool use, and code execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
server/src/__tests__/dev-watch-ignore.test.ts | 3 +++
server/src/dev-watch-ignore.ts | 13 +++++++++++++
2 files changed, 16 insertions(+)
diff --git a/server/src/__tests__/dev-watch-ignore.test.ts b/server/src/__tests__/dev-watch-ignore.test.ts
index 4f54e609880..42452310f5b 100644
--- a/server/src/__tests__/dev-watch-ignore.test.ts
+++ b/server/src/__tests__/dev-watch-ignore.test.ts
@@ -36,6 +36,9 @@ describe("resolveServerDevWatchIgnorePaths", () => {
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite")));
expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist"));
expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist")));
+ const sharedWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees");
+ expect(ignorePaths).toContain(sharedWorktreesRoot);
+ expect(ignorePaths).toContain(`${sharedWorktreesRoot.replaceAll(path.sep, "/")}/**`);
expect(ignorePaths).toContain("**/{node_modules,bower_components,vendor}/**");
expect(ignorePaths).toContain("**/.vite-temp/**");
});
diff --git a/server/src/dev-watch-ignore.ts b/server/src/dev-watch-ignore.ts
index 4fe3769d165..20c97f33ef9 100644
--- a/server/src/dev-watch-ignore.ts
+++ b/server/src/dev-watch-ignore.ts
@@ -18,6 +18,11 @@ function addIgnorePath(target: Set, candidate: string): void {
}
export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] {
+ const checkoutRoot = path.dirname(serverRoot);
+ const linkedWorktreesRoot = path.dirname(checkoutRoot);
+ const isLinkedWorktree =
+ path.basename(linkedWorktreesRoot) === "worktrees" &&
+ path.basename(path.dirname(linkedWorktreesRoot)) === ".paperclip";
const ignorePaths = new Set([
"**/{node_modules,bower_components,vendor}/**",
"**/.vite-temp/**",
@@ -28,6 +33,14 @@ export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] {
"../ui/node_modules/.vite-temp",
"../ui/.vite",
"../ui/dist",
+ // Git worktrees live under /.paperclip/worktrees, each a full
+ // checkout (source + its own .paperclip). Watching them can add hundreds
+ // of thousands of files, stalling tsx watch before it ever spawns the
+ // server. None of them are part of this checkout's reloadable source.
+ // A linked checkout has serverRoot at
+ // /.paperclip/worktrees//server. In that case, the shared
+ // worktree directory is the checkout's parent, not a nested path.
+ isLinkedWorktree ? "../.." : "../.paperclip/worktrees",
// npm install during reinstall would trigger a restart mid-request
// if tsx watch sees the new files. Exclude the managed plugins dir.
process.env.HOME + "/.paperclip/adapter-plugins",
From 4e9a78db5860f3ed0e18ed2f0162ccc783eea7a5 Mon Sep 17 00:00:00 2001
From: scotttong
Date: Fri, 7 Aug 2026 18:32:49 -0700
Subject: [PATCH 014/201] feat(ui): persist task chat composer drafts (#11076)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - People send task instructions through the board task chat.
> - A page refresh or task switch can discard an unfinished message in
the redesigned composer.
> - The existing task chat already supplies a task-specific draft key.
> - The redesigned composer must use that key without changing
attachment or send behavior.
> - This pull request restores, saves, and clears text drafts in the
redesigned composer.
> - The benefit is that users can return to unfinished task messages
without losing their text.
## Linked Issues or Issue Description
Related prior work: #11070. This pull request extracts only the final
composer draft behavior from that larger draft.
**Subsystem affected**
ui/ — React + Vite board UI.
**Problem or motivation**
The redesigned task chat composer does not use the draft key that the
task thread already provides. A refresh, navigation, or unmount can lose
an unfinished message.
**Proposed solution**
Persist text drafts by task key in local storage. Restore a draft when
the composer mounts. Save changes after a short delay and flush pending
text during unload or unmount. Clear the draft only after a successful
send.
**Alternatives considered**
The composer could save on every keystroke. A short delay avoids
unnecessary synchronous storage writes. The feature could also stay in
the larger predecessor PR, but a focused PR is easier to review and
verify.
**Roadmap alignment**
This is a focused usability improvement for the task conversation
surface. It does not add or duplicate a roadmap capability.
## What Changed
- Added safe draft storage helpers for load, save, and clear operations.
- Connected the task-specific draft key to the redesigned task chat
composer.
- Preserved drafts across debounce windows, unmounts, page unloads,
failed sends, and React Strict Mode probes.
- Cleared drafts after successful sends without changing current
attachment safeguards.
- Added focused composer and thread integration tests.
## Verification
- `pnpm check:token-gates`
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm --filter @paperclipai/ui exec vitest run
src/components/task-chat/TaskChatComposer.test.tsx
src/components/TaskChatThread.test.tsx`
## Risks
- Local storage can be unavailable or full. The helpers catch storage
errors and keep the composer usable.
- Only text is persisted. Attachments, work mode, and assignee
selections remain session state.
- Draft keys remain task-scoped, so text does not cross task boundaries.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
OpenAI Codex with model `gpt-5`. The context-window size is not exposed
in this environment. The model used agentic reasoning, tool use, code
execution, and test execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
ui/src/components/TaskChatThread.test.tsx | 65 ++++++++
ui/src/components/TaskChatThread.tsx | 2 +
.../task-chat/TaskChatComposer.test.tsx | 147 +++++++++++++++++-
.../components/task-chat/TaskChatComposer.tsx | 68 +++++++-
ui/src/lib/composer-draft.ts | 38 +++++
5 files changed, 313 insertions(+), 7 deletions(-)
create mode 100644 ui/src/components/TaskChatThread.test.tsx
create mode 100644 ui/src/lib/composer-draft.ts
diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx
new file mode 100644
index 00000000000..86581ff2a6b
--- /dev/null
+++ b/ui/src/components/TaskChatThread.test.tsx
@@ -0,0 +1,65 @@
+// @vitest-environment jsdom
+
+import type { ReactElement } from "react";
+import { forwardRef, useImperativeHandle, type ForwardedRef } from "react";
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { TaskChatThread } from "./TaskChatThread";
+
+vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
+ useLiveRunTranscripts: () => ({ transcriptByRun: new Map() }),
+}));
+vi.mock("@/context/SidebarContext", () => ({
+ useSidebar: () => ({ isMobile: false }),
+}));
+vi.mock("@/hooks/useIssuePlanDocument", () => ({
+ useIssuePlanDocument: () => ({ data: null }),
+}));
+vi.mock("@/components/MarkdownEditor", () => ({
+ MarkdownEditor: forwardRef(function MockMarkdownEditor(
+ { value }: { value: string },
+ ref: ForwardedRef,
+ ) {
+ useImperativeHandle(ref, () => ({ insertMarkdown: () => {}, focus: () => {} }));
+ return {value}
;
+ }),
+}));
+
+let container: HTMLDivElement;
+let root: Root | null = null;
+
+beforeEach(() => {
+ localStorage.clear();
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+});
+
+afterEach(() => {
+ flushSync(() => root?.unmount());
+ root = null;
+ container.remove();
+ localStorage.clear();
+});
+
+function render(ui: ReactElement) {
+ flushSync(() => root!.render(ui));
+}
+
+describe("TaskChatThread draft pass-through", () => {
+ it("forwards draftKey so the composer restores a task's saved draft", () => {
+ localStorage.setItem("task-chat-draft:issue-1", "half-written thought");
+
+ render(
+ {}}
+ draftKey="task-chat-draft:issue-1"
+ />,
+ );
+
+ expect(container.querySelector('[data-testid="mock-editor"]')?.textContent)
+ .toBe("half-written thought");
+ });
+});
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 27bfa3fb348..769a8754d92 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -116,6 +116,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
+ draftKey,
} = props;
const linkedRunMetaById = useMemo(() => {
@@ -527,6 +528,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
currentAssigneeValue={currentAssigneeValue}
issueStatus={issueStatus}
mobile={isMobile}
+ draftKey={draftKey}
/>
{footer}
diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx
index 215580b0886..044f049f03d 100644
--- a/ui/src/components/task-chat/TaskChatComposer.test.tsx
+++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx
@@ -1,11 +1,12 @@
// @vitest-environment jsdom
-import type { ReactElement } from "react";
+import { StrictMode, type ReactElement } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildAgentMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { TaskChatComposer } from "./TaskChatComposer";
+import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft";
/**
* MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor
@@ -149,6 +150,7 @@ let root: Root | null = null;
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
beforeEach(() => {
+ localStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -252,6 +254,7 @@ describe("TaskChatComposer", () => {
pressKey("Enter", { metaKey: true });
await flushAsync();
+ await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
expect(editable().textContent).toBe("");
@@ -585,4 +588,146 @@ describe("TaskChatComposer", () => {
const trigger = container.querySelector('[data-testid="task-chat-composer-assignee"]');
expect(trigger?.textContent).toContain("Sam");
});
+
+ describe("draft persistence", () => {
+ const draftKey = "task-chat-draft:issue-1";
+
+ it("restores a saved draft on mount", () => {
+ localStorage.setItem(draftKey, "unsent draft");
+
+ render(
);
+
+ expect(editable().textContent).toBe("unsent draft");
+ expect(sendButton().disabled).toBe(false);
+ });
+
+ it("preserves a restored draft through the StrictMode effect probe", () => {
+ localStorage.setItem(draftKey, "still here");
+
+ render(
+
+
+ ,
+ );
+
+ expect(editable().textContent).toBe("still here");
+ expect(localStorage.getItem(draftKey)).toBe("still here");
+ });
+
+ it("saves after the debounce and flushes a pending value on unmount", () => {
+ vi.useFakeTimers();
+ try {
+ render(
);
+ typeText("work in progress");
+ expect(localStorage.getItem(draftKey)).toBeNull();
+
+ vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS);
+ expect(localStorage.getItem(draftKey)).toBe("work in progress");
+
+ typeText("save before leaving");
+ flushSync(() => root?.unmount());
+ root = null;
+ expect(localStorage.getItem(draftKey)).toBe("save before leaving");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("flushes a pending value before page unload", () => {
+ vi.useFakeTimers();
+ try {
+ render(
);
+ typeText("save before reload");
+
+ window.dispatchEvent(new Event("beforeunload"));
+
+ expect(localStorage.getItem(draftKey)).toBe("save before reload");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("clears the saved draft only after a successful send", async () => {
+ localStorage.setItem(draftKey, "queued message");
+ const onAdd = vi.fn().mockResolvedValue(undefined);
+ render(
);
+
+ pressKey("Enter", { metaKey: true });
+ await flushAsync();
+
+ expect(onAdd).toHaveBeenCalledWith("queued message", undefined, undefined);
+ expect(localStorage.getItem(draftKey)).toBeNull();
+ });
+
+ it("keeps text entered while an earlier send is pending", async () => {
+ let resolveSend!: () => void;
+ const onAdd = vi.fn().mockReturnValue(new Promise
((resolve) => {
+ resolveSend = resolve;
+ }));
+ render( );
+ typeText("first message");
+
+ pressKey("Enter", { metaKey: true });
+ await flushAsync();
+ typeText("next message");
+ resolveSend();
+ await flushAsync();
+ await flushAsync();
+
+ expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined);
+ expect(editable().textContent).toBe("next message");
+ expect(localStorage.getItem(draftKey)).toBe("next message");
+ });
+
+ it("keeps an attachment added while an earlier send is pending", async () => {
+ let resolveSend!: () => void;
+ const onAdd = vi.fn().mockReturnValue(new Promise((resolve) => {
+ resolveSend = resolve;
+ }));
+ const onAttachImage = vi.fn().mockResolvedValue({
+ contentPath: "/attachments/next.txt",
+ originalFilename: "next.txt",
+ });
+ render(
+ ,
+ );
+ typeText("first message");
+
+ pressKey("Enter", { metaKey: true });
+ await flushAsync();
+ pasteFiles([new File(["next"], "next.txt", { type: "text/plain" })]);
+ await flushAsync();
+ resolveSend();
+ await flushAsync();
+ await flushAsync();
+
+ expect(onAdd).toHaveBeenCalledWith("first message", undefined, undefined);
+ expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')?.textContent)
+ .toContain("next.txt");
+ });
+
+ it("keeps the body and saved draft when sending fails", async () => {
+ vi.useFakeTimers();
+ try {
+ const onAdd = vi.fn().mockRejectedValue(new Error("network down"));
+ render( );
+ typeText("do not lose this");
+ vi.advanceTimersByTime(DRAFT_DEBOUNCE_MS);
+ vi.useRealTimers();
+
+ pressKey("Enter", { metaKey: true });
+ await flushAsync();
+
+ expect(editable().textContent).toBe("do not lose this");
+ expect(localStorage.getItem(draftKey)).toBe("do not lose this");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+ });
});
diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx
index 5c7cca635b9..03056cf0201 100644
--- a/ui/src/components/task-chat/TaskChatComposer.tsx
+++ b/ui/src/components/task-chat/TaskChatComposer.tsx
@@ -1,4 +1,5 @@
import {
+ useEffect,
useRef,
useState,
type ChangeEvent,
@@ -6,6 +7,7 @@ import {
type CSSProperties,
} from "react";
import { cn } from "@/lib/utils";
+import { DRAFT_DEBOUNCE_MS, clearDraft, loadDraft, saveDraft } from "@/lib/composer-draft";
import { ArrowUp, Check, ChevronDown, Loader2, Plus, X } from "lucide-react";
import {
DropdownMenu,
@@ -55,6 +57,8 @@ interface TaskChatComposerProps {
issueStatus?: string;
/** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */
mobile?: boolean;
+ /** Storage key used to restore, persist, and clear this task's text draft. */
+ draftKey?: string;
}
/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */
@@ -146,16 +150,49 @@ export function TaskChatComposer({
currentAssigneeValue = "",
issueStatus,
mobile = false,
+ draftKey,
}: TaskChatComposerProps) {
- const [body, setBody] = useState("");
+ const [body, setBody] = useState(() => (draftKey ? loadDraft(draftKey) : ""));
const [submitting, setSubmitting] = useState(false);
const [pendingMode, setPendingMode] = useState(workMode);
const [pendingAssignee, setPendingAssignee] = useState(null);
const [attachments, setAttachments] = useState([]);
+ const attachmentsRef = useRef(attachments);
+ attachmentsRef.current = attachments;
+ const pendingAssigneeRef = useRef(pendingAssignee);
+ pendingAssigneeRef.current = pendingAssignee;
const fileInputRef = useRef(null);
const editorRef = useRef(null);
const bodyRef = useRef(body);
bodyRef.current = body;
+ const draftTimer = useRef | null>(null);
+
+ useEffect(() => {
+ if (!draftKey) return;
+ setBody(loadDraft(draftKey));
+ }, [draftKey]);
+
+ useEffect(() => {
+ if (!draftKey) return;
+ if (draftTimer.current) clearTimeout(draftTimer.current);
+ draftTimer.current = setTimeout(() => {
+ saveDraft(draftKey, body);
+ }, DRAFT_DEBOUNCE_MS);
+ }, [body, draftKey]);
+
+ useEffect(() => {
+ return () => {
+ if (draftTimer.current) clearTimeout(draftTimer.current);
+ if (draftKey) saveDraft(draftKey, bodyRef.current);
+ };
+ }, [draftKey]);
+
+ useEffect(() => {
+ if (!draftKey) return;
+ const flushDraft = () => saveDraft(draftKey, bodyRef.current);
+ window.addEventListener("beforeunload", flushDraft);
+ return () => window.removeEventListener("beforeunload", flushDraft);
+ }, [draftKey]);
const modeMeta = workModeMetaFor(pendingMode);
const canAcceptFiles = Boolean(onAttachImage || onImageUpload);
@@ -278,7 +315,10 @@ export function TaskChatComposer({
const uploadFailed = attachments.some((item) => item.status === "error");
async function submit() {
- const trimmed = bodyRef.current.trim();
+ const submittedBody = bodyRef.current;
+ const submittedAttachments = attachmentsRef.current;
+ const submittedAssignee = pendingAssigneeRef.current;
+ const trimmed = submittedBody.trim();
if (
(!trimmed && attachedRefs.length === 0) ||
uploadPending ||
@@ -297,16 +337,32 @@ export function TaskChatComposer({
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined;
setSubmitting(true);
- setBody("");
try {
if (pendingMode !== workMode && onWorkModeChange) {
await onWorkModeChange(pendingMode);
}
await onAdd(fullBody, reopen, reassignment);
- setAttachments([]);
- setPendingAssignee(null);
+ if (bodyRef.current === submittedBody) {
+ bodyRef.current = "";
+ if (draftTimer.current) {
+ clearTimeout(draftTimer.current);
+ draftTimer.current = null;
+ }
+ if (draftKey) clearDraft(draftKey);
+ setBody("");
+ } else if (draftKey) {
+ // The editor stays writable while the request is pending. Preserve
+ // text entered after this submission started as the next draft.
+ saveDraft(draftKey, bodyRef.current);
+ }
+ if (attachmentsRef.current === submittedAttachments) {
+ setAttachments([]);
+ }
+ if (pendingAssigneeRef.current === submittedAssignee) {
+ setPendingAssignee(null);
+ }
} catch {
- setBody(trimmed); // restore on failure (chips stay for retry)
+ // Keep the body and its draft available for retry.
} finally {
setSubmitting(false);
}
diff --git a/ui/src/lib/composer-draft.ts b/ui/src/lib/composer-draft.ts
new file mode 100644
index 00000000000..400007ccacf
--- /dev/null
+++ b/ui/src/lib/composer-draft.ts
@@ -0,0 +1,38 @@
+/**
+ * Per-task composer draft persistence, shared by the chat composers.
+ *
+ * Draft text is kept in localStorage under the caller-provided key. All
+ * access is guarded so disabled or full storage never throws into React.
+ * Empty drafts remove the key, and only text is persisted.
+ */
+
+/** Debounce before a keystroke lands in localStorage. */
+export const DRAFT_DEBOUNCE_MS = 800;
+
+export function loadDraft(draftKey: string): string {
+ try {
+ return localStorage.getItem(draftKey) ?? "";
+ } catch {
+ return "";
+ }
+}
+
+export function saveDraft(draftKey: string, value: string) {
+ try {
+ if (value.trim()) {
+ localStorage.setItem(draftKey, value);
+ } else {
+ localStorage.removeItem(draftKey);
+ }
+ } catch {
+ // Ignore localStorage failures.
+ }
+}
+
+export function clearDraft(draftKey: string) {
+ try {
+ localStorage.removeItem(draftKey);
+ } catch {
+ // Ignore localStorage failures.
+ }
+}
From cc35c3c395ae0da3bb7aea107e02eca479267beb Mon Sep 17 00:00:00 2001
From: scotttong
Date: Fri, 7 Aug 2026 18:41:52 -0700
Subject: [PATCH 015/201] feat: structure and humanize recovery notices
(#11075)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Paperclip posts system comments when automatic run recovery cannot
continue
> - These comments currently mix the main event with recovery
identifiers and routing details
> - The task chat shell also renders these comments as large raw text
blocks
> - Operators need a short explanation first and inspectable evidence on
demand
> - This pull request emits structured recovery notices and renders them
as compact humanized rows
> - The benefit is a quieter task thread that keeps the full recovery
evidence available
## Linked Issues or Issue Description
Related prior extraction source: #11070. This pull request replaces only
its structured recovery notice slice with a focused branch based on
current master.
**What existing behavior does this improve?**
Paperclip recovery escalations and the experimental task chat
system-comment renderer.
**Current behavior**
Recovery escalation comments put action identifiers, owner details, run
details, and failure codes into the visible markdown body. The task chat
shell renders the complete system comment as a large text block.
**Proposed behavior**
The server emits a short system notice with typed metadata sections. The
task chat shell classifies known recovery families and renders one
compact row. An operator can expand the row to inspect the full body and
metadata.
**Reason and benefit**
The main thread stays readable during repeated recovery activity. Typed
links and evidence remain available without exposing raw failure text in
the default view.
**Breaking changes**
The visible recovery comment body is shorter. Recovery action
deduplication now reads the structured metadata and still recognizes
legacy body markers. No API schema or database migration changes.
## What Changed
- Emit stranded recovery escalations with `system_notice` presentation
and typed recovery, owner, run, and failure-code metadata.
- Share bounded metadata row builders across recovery notice producers
and preserve legacy deduplication compatibility.
- Humanize known recovery notice families and render compact expandable
task-chat rows.
- Route system-authored comments ahead of derived agent authorship so
recovery notices do not appear as agent bubbles.
- Add focused server and UI regression coverage.
## Verification
- `pnpm check:token-gates` — 3/3 clean.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/shared typecheck` — passed.
- `pnpm --filter @paperclipai/shared exec vitest run
src/validators/issue.test.ts` — 32 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/services/recovery/stranded-notice.test.ts
src/__tests__/issue-recovery-actions.test.ts` — 57 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/services/recovery/successful-run-handoff.test.ts
src/services/recovery/stranded-notice.test.ts` — 39 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts -t 'escalates an
exhausted failed successful-run handoff without using generic
continuation recovery first|escalates an exhausted successful handoff
run that still leaves no disposition'` — 2 tests passed.
- `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-process-recovery.test.ts -t 'blocks assigned
todo work after the one automatic dispatch recovery was already used'` —
passed.
- `pnpm --filter @paperclipai/ui exec vitest run
src/lib/system-notice-humanizer.test.ts
src/components/task-chat/TaskChatSystemNotice.test.tsx
src/components/task-chat/task-chat-adapter.test.ts` — 15 tests passed.
- Storybook visual baselines were not updated because this chat-shell
path has no affected snapshot baseline. Focused rendering tests and
token gates cover this change.
## Risks
- Consumers that parse recovery action identifiers from comment markdown
must move to structured metadata. Server deduplication remains backward
compatible with legacy comments.
- The humanizer uses stable recovery-family phrases. Unknown notices use
a generic truncated first-sentence fallback.
- The UI changes only the experimental task chat presentation. The
stored comment body and expanded metadata remain available.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex with GPT-5, reasoning mode, repository tools, shell
execution, and GitHub integration. The runtime did not expose a
context-window size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
packages/shared/src/types/issue.ts | 1 +
packages/shared/src/validators/issue.test.ts | 11 +-
packages/shared/src/validators/issue.ts | 1 +
.../heartbeat-process-recovery.test.ts | 78 +++++---
...tbeat-workspace-branch-containment.test.ts | 20 +-
.../__tests__/issue-recovery-actions.test.ts | 28 ++-
server/src/services/heartbeat.ts | 118 ++----------
server/src/services/recovery/index.ts | 14 ++
server/src/services/recovery/notice-format.ts | 68 +++++++
server/src/services/recovery/service.ts | 141 +++++++-------
.../services/recovery/stranded-notice.test.ts | 176 ++++++++++++++++++
.../src/services/recovery/stranded-notice.ts | 158 ++++++++++++++++
.../recovery/successful-run-handoff.test.ts | 30 ++-
.../recovery/successful-run-handoff.ts | 61 +-----
ui/src/components/SystemNotice.tsx | 49 +++--
.../components/task-chat/TaskChatBubble.tsx | 8 +-
.../task-chat/TaskChatSystemNotice.test.tsx | 123 ++++++++++++
.../task-chat/TaskChatSystemNotice.tsx | 100 ++++++++++
.../task-chat/task-chat-adapter.test.ts | 63 +++++++
.../components/task-chat/task-chat-adapter.ts | 19 +-
.../components/task-chat/task-chat-model.ts | 12 ++
ui/src/index.css | 1 +
ui/src/lib/system-notice-comment.ts | 2 +-
ui/src/lib/system-notice-humanizer.test.ts | 100 ++++++++++
ui/src/lib/system-notice-humanizer.ts | 92 +++++++++
25 files changed, 1166 insertions(+), 308 deletions(-)
create mode 100644 server/src/services/recovery/notice-format.ts
create mode 100644 server/src/services/recovery/stranded-notice.test.ts
create mode 100644 server/src/services/recovery/stranded-notice.ts
create mode 100644 ui/src/components/task-chat/TaskChatSystemNotice.test.tsx
create mode 100644 ui/src/components/task-chat/TaskChatSystemNotice.tsx
create mode 100644 ui/src/lib/system-notice-humanizer.test.ts
create mode 100644 ui/src/lib/system-notice-humanizer.ts
diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts
index 36c8bc309ac..cf79698b3f7 100644
--- a/packages/shared/src/types/issue.ts
+++ b/packages/shared/src/types/issue.ts
@@ -992,6 +992,7 @@ export interface IssueCommentMetadataAgentLinkRow extends IssueCommentMetadataRo
export interface IssueCommentMetadataRunLinkRow extends IssueCommentMetadataRowBase {
type: "run_link";
runId: string;
+ agentId?: string | null;
title?: string | null;
}
diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts
index 762d4538bf1..20161a2094d 100644
--- a/packages/shared/src/validators/issue.test.ts
+++ b/packages/shared/src/validators/issue.test.ts
@@ -277,7 +277,12 @@ describe("issue validators", () => {
rows: [
{ type: "key_value", label: "Cause", value: "successful_run_missing_state" },
{ type: "issue_link", label: "Source issue", identifier: "PAP-3440" },
- { type: "run_link", label: "Run", runId: "11111111-1111-4111-8111-111111111111" },
+ {
+ type: "run_link",
+ label: "Run",
+ runId: "11111111-1111-4111-8111-111111111111",
+ agentId: "22222222-2222-4222-8222-222222222222",
+ },
],
},
],
@@ -288,6 +293,10 @@ describe("issue validators", () => {
expect(parsed.presentation?.density).toBe("compact");
expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111");
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
+ expect(parsed.metadata?.sections[0]?.rows[2]).toMatchObject({
+ type: "run_link",
+ agentId: "22222222-2222-4222-8222-222222222222",
+ });
});
it("rejects unknown issue comment presentation densities", () => {
diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts
index b67d7e1ae69..557519c2136 100644
--- a/packages/shared/src/validators/issue.ts
+++ b/packages/shared/src/validators/issue.ts
@@ -631,6 +631,7 @@ const issueCommentMetadataAgentLinkRowSchema = issueCommentMetadataBaseRowSchema
const issueCommentMetadataRunLinkRowSchema = issueCommentMetadataBaseRowSchema.extend({
type: z.literal("run_link"),
runId: z.string().uuid(),
+ agentId: z.string().uuid().nullable().optional(),
title: z.string().trim().min(1).max(160).nullable().optional(),
}).strict();
diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts
index 789c28552d1..84ba03a1b51 100644
--- a/server/src/__tests__/heartbeat-process-recovery.test.ts
+++ b/server/src/__tests__/heartbeat-process-recovery.test.ts
@@ -116,6 +116,7 @@ import {
SUCCESSFUL_RUN_HANDOFF_EXHAUSTED_NOTICE_BODY,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY,
SUCCESSFUL_RUN_MISSING_STATE_REASON,
+ noticeMetadataReferencesRecoveryAction,
} from "../services/recovery/index.ts";
import {
UNMANAGED_BACKGROUND_TASK_LIVENESS_REASON,
@@ -130,6 +131,11 @@ if (!embeddedPostgresSupport.supported) {
);
}
+function commentMetadataRows(comment: { metadata?: unknown } | null | undefined) {
+ const metadata = comment?.metadata as { sections?: Array<{ rows?: unknown[] }> } | null | undefined;
+ return (metadata?.sections ?? []).flatMap((section) => section.rows ?? []) as Array>;
+}
+
function spawnAliveProcess() {
return spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdio: "ignore",
@@ -2489,8 +2495,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation");
- expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
- expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
+ expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" });
+ expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true);
+ expect(commentMetadataRows(comments[0]).some((row) =>
+ row.type === "agent_link" && row.label === "Recovery owner" && row.name === "CodexCoder",
+ )).toBe(true);
});
it("blocks failed recovery work in place during immediate terminal-run cleanup", async () => {
@@ -2565,6 +2574,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments[0]?.body).toContain("recovery issues do not create nested `stranded_issue_recovery` issues");
expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
expect(comments[0]?.body).not.toContain("sk-test-recovery-secret");
+ expect(JSON.stringify(comments[0]?.metadata)).not.toContain("sk-test-recovery-secret");
expect(comments[0]?.presentation).toMatchObject({
kind: "system_notice",
tone: "warning",
@@ -2944,7 +2954,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
authorType: "system",
body: expect.stringContaining("Agent failed to resume after approval: `adapter_failed` — needs attention"),
});
- expect(comments[0]?.body).toContain("Recovery action:");
+ expect(commentMetadataRows(comments[0]).some((row) => row.label === "Recovery action")).toBe(true);
const interaction = await db
.select({ result: issueThreadInteractions.result })
@@ -4697,7 +4707,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
const recoveryComment = comments.find((comment) =>
comment.body.includes("pending execution-review participant once") &&
- comment.body.includes(`Recovery action: \`${recoveryAction.id}\``),
+ noticeMetadataReferencesRecoveryAction(comment.metadata, recoveryAction.id),
);
expect(recoveryComment).toBeTruthy();
@@ -5511,25 +5521,18 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried dispatch");
- expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
- expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
- expect(comments[0]?.body).toContain(`Recovery owner: [${longRecoveryOwnerName}]`);
- expect(comments[0]?.presentation).toMatchObject({
- kind: "system_notice",
- tone: "warning",
- title: `${`Recovery: retries exhausted — moved to blocked (owner: ${longRecoveryOwnerName})`.slice(0, 159)}…`,
- density: "compact",
- });
- expect(comments[0]?.metadata).toMatchObject({
- version: 1,
- sections: [expect.objectContaining({
- rows: expect.arrayContaining([
- expect.objectContaining({ type: "key_value", label: "Recovery action", value: recoveryAction.id }),
- expect.objectContaining({ type: "key_value", label: "Cause", value: "process_lost" }),
- expect.objectContaining({ type: "agent_link", label: "Recovery owner", name: "R".repeat(160) }),
- ]),
- })],
- });
+ expect(comments[0]?.body).not.toContain("sk-test-recovery-secret");
+ expect(JSON.stringify(comments[0]?.metadata)).not.toContain("sk-test-recovery-secret");
+ const failureSummary = commentMetadataRows(comments[0]).find((row) =>
+ row.type === "key_value" && row.label === "Failure summary"
+ );
+ expect(failureSummary).toMatchObject({ type: "key_value", label: "Failure summary" });
+ expect(failureSummary?.type === "key_value" ? failureSummary.value : "").toContain("Authorization");
+ expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" });
+ expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true);
+ expect(commentMetadataRows(comments[0]).some((row) =>
+ row.type === "agent_link" && row.label === "Recovery owner" && row.name === longRecoveryOwnerName.slice(0, 160),
+ )).toBe(true);
});
it("blocks an already stranded recovery issue without creating a recovery child", async () => {
@@ -5933,9 +5936,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation");
- expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
- expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
- expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
+ expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" });
+ expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true);
+ expect(commentMetadataRows(comments[0]).some((row) =>
+ row.type === "agent_link" && row.label === "Recovery owner" && row.name === "CodexCoder",
+ )).toBe(true);
});
it("redacts error-code-only stranded recovery failures in issue copy", async () => {
@@ -5966,8 +5971,15 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
expect(comments).toHaveLength(1);
- expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
+ // The short structured body carries no failure details; the normalized
+ // failure code surfaces only as a metadata row.
+ expect(comments[0]?.body).not.toContain("adapter_exit_code");
expect(comments[0]?.body).not.toContain("- Failure: none recorded");
+ expect(commentMetadataRows(comments[0])).toContainEqual({
+ type: "key_value",
+ label: "Failure code",
+ value: "adapter_exit_code",
+ });
});
it("keeps retrying transient adapter_failed continuation runs before the cap", async () => {
@@ -6060,7 +6072,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("retried continuation");
expect(comments[0]?.body).toContain("3× attempts");
- expect(comments[0]?.body).toContain("Latest cause: `adapter_failed`");
+ expect(commentMetadataRows(comments[0])).toContainEqual({
+ type: "key_value",
+ label: "Failure code",
+ value: "adapter_failed",
+ });
});
it("does not count mixed-cause continuation failures toward the transient cap", async () => {
@@ -6687,8 +6703,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(comments).toHaveLength(1);
expect(comments[0]?.body).toContain("automatically retried continuation");
expect(comments[0]?.body).toContain("still has no live execution path");
- expect(comments[0]?.body).toContain(`Recovery action: \`${recoveryAction.id}\``);
- expect(comments[0]?.body).toContain("Recovery owner: [CodexCoder]");
+ expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, recoveryAction.id)).toBe(true);
+ expect(commentMetadataRows(comments[0]).some((row) =>
+ row.type === "agent_link" && row.label === "Recovery owner" && row.name === "CodexCoder",
+ )).toBe(true);
});
it("allows one productive-terminal recovery after regular continuation recovery made progress", async () => {
diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts
index ed899e0dfc7..38394581ea4 100644
--- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts
+++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts
@@ -37,6 +37,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js";
import { heartbeatService } from "../services/heartbeat.ts";
+import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/index.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
@@ -221,7 +222,7 @@ async function waitForContainmentSideEffects(input: {
const hasRecoveryActionComment = recoveryActionId
? comments.some((comment) =>
comment.issueId === input.sourceIssueId &&
- comment.body.includes(`Recovery action: \`${recoveryActionId}\``))
+ noticeMetadataReferencesRecoveryAction(comment.metadata, recoveryActionId))
: false;
if (
source?.status === "blocked" &&
@@ -685,7 +686,10 @@ async function expectContainedWorkspaceBranchFailure(input: {
}),
});
- expect(comments.filter((comment) => comment.issueId === input.sourceIssueId && comment.body.includes(`Recovery action: \`${action.id}\``))).toHaveLength(1);
+ expect(comments.filter((comment) =>
+ comment.issueId === input.sourceIssueId &&
+ noticeMetadataReferencesRecoveryAction(comment.metadata, action.id),
+ )).toHaveLength(1);
expect(comments.filter((comment) => comment.issueId === input.sameWorkspaceSiblingId)).toHaveLength(0);
expect(comments.filter((comment) => comment.issueId === input.otherWorkspaceSiblingId)).toHaveLength(0);
}
@@ -812,14 +816,10 @@ async function expectForwardBranchReconciled(input: {
]),
);
if (resolvedRecoveryActionId) {
- expect(comments).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- authorType: "system",
- body: expect.stringContaining(`Recovery action: \`${resolvedRecoveryActionId}\``),
- }),
- ]),
- );
+ expect(comments.some((comment) =>
+ comment.authorType === "system" &&
+ noticeMetadataReferencesRecoveryAction(comment.metadata, resolvedRecoveryActionId),
+ )).toBe(true);
}
const activities = await input.db
diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts
index 236f884c345..f5a2d036ddf 100644
--- a/server/src/__tests__/issue-recovery-actions.test.ts
+++ b/server/src/__tests__/issue-recovery-actions.test.ts
@@ -27,6 +27,7 @@ import { issueRoutes } from "../routes/issues.js";
import { buildPaperclipWakePayload } from "../services/heartbeat.js";
import { issueRecoveryActionService } from "../services/issue-recovery-actions.js";
import { recoveryService } from "../services/recovery/service.js";
+import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/successful-run-handoff.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -1143,21 +1144,14 @@ describeEmbeddedPostgres("issue recovery actions", () => {
});
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id));
- expect(comments).toHaveLength(1);
- expect(comments[0]?.presentation).toMatchObject({
+ const escalationComments = comments.filter((comment) =>
+ noticeMetadataReferencesRecoveryAction(comment.metadata, actionRows[0]!.id),
+ );
+ expect(escalationComments).toHaveLength(1);
+ expect(escalationComments[0]?.presentation).toMatchObject({
kind: "system_notice",
- tone: "warning",
- title: "Recovery: workspace validation failed — moved to blocked (owner: CTO)",
- density: "compact",
- });
- expect(comments[0]?.metadata).toMatchObject({
- version: 1,
- sections: [expect.objectContaining({
- rows: expect.arrayContaining([
- expect.objectContaining({ type: "key_value", label: "Recovery action", value: actionRows[0]?.id }),
- expect.objectContaining({ type: "key_value", label: "Cause", value: "workspace_validation_failed" }),
- ]),
- })],
+ tone: "danger",
+ title: "Workspace validation failed",
});
expect(enqueueWakeup).toHaveBeenCalledTimes(2);
expect(enqueueWakeup).toHaveBeenCalledWith(
@@ -1231,7 +1225,11 @@ describeEmbeddedPostgres("issue recovery actions", () => {
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, sourceIssue.id));
expect(comments).toHaveLength(1);
- expect(comments[0]?.body).toContain("Recovery action:");
+ // Dedupe for structured notices is metadata-based: the short body no longer
+ // carries the `Recovery action: \`id\`` marker line.
+ expect(comments[0]?.body).not.toContain("Recovery action:");
+ expect(noticeMetadataReferencesRecoveryAction(comments[0]?.metadata, actionRows[0]!.id)).toBe(true);
+ expect(comments[0]?.presentation).toMatchObject({ kind: "system_notice", tone: "danger" });
});
it("does not create nested recovery artifacts when issue-backed fallback work itself fails", async () => {
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index f9eddcbd5b8..5ffbfe143b8 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -224,6 +224,12 @@ import {
readContinuationAttempt,
} from "./recovery/index.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
+import {
+ buildConfigurationIncompleteRecoveryNoticeSeed,
+ buildExecutionReviewParticipantRecoveryNoticeSeed,
+ buildImmediateExecutionPathRecoveryNoticeSeed,
+ buildWorkspaceValidationRecoveryNoticeSeed,
+} from "./recovery/stranded-notice.js";
import {
recoveryAssigneeAdapterOverrides,
withRecoveryModelProfileHint,
@@ -3616,30 +3622,6 @@ export function summarizeHeartbeatRunListResultJson(input: {
return Object.keys(summary).length > 0 ? summary : null;
}
-function summarizeRunFailureForIssueComment(
- run: Pick | null | undefined,
-) {
- if (!run) return null;
-
- const errorCode = readNonEmptyString(run.errorCode)?.trim() ?? null;
- const rawError = readNonEmptyString(run.error)?.trim() ?? null;
- const apiMessageMatch = rawError?.match(/"message"\s*:\s*"([^"]+)"/);
- const firstLine = rawError
- ?.split(/\r?\n/)
- .map((line) => line.trim())
- .find(Boolean) ?? null;
- const summarySource = apiMessageMatch?.[1] ?? firstLine;
- const summary =
- summarySource && summarySource.length > 240
- ? `${summarySource.slice(0, 237)}...`
- : summarySource;
-
- if (errorCode && summary) return ` Latest retry failure: \`${errorCode}\` - ${summary}.`;
- if (errorCode) return ` Latest retry failure: \`${errorCode}\`.`;
- if (summary) return ` Latest retry failure: ${summary}.`;
- return null;
-}
-
function didAutomaticRecoveryFail(
latestRun: Pick | null,
expectedRetryReason:
@@ -16379,73 +16361,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
}
}
- function buildImmediateExecutionPathRecoveryComment(input: {
- status: "todo" | "in_progress";
- latestRun: Pick | null | undefined;
- }) {
- const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
- if (input.status === "todo") {
- return (
- "Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery, " +
- `but it still has no live execution path.${failureSummary ?? ""} ` +
- "Moving it to `blocked` so it is visible for intervention."
- );
- }
-
- return (
- "Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run " +
- `recovery, but it still has no live execution path.${failureSummary ?? ""} ` +
- "Moving it to `blocked` so it is visible for intervention."
- );
- }
-
- function buildWorkspaceValidationRecoveryComment(input: {
- latestRun:
- | Pick
- | null
- | undefined;
- }) {
- const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
- const validationReason = readNonEmptyString(
- readWorkspaceValidationPayloadFromRun(input.latestRun).reason,
- );
- if (validationReason === "git_worktree_base_materialization_failed") {
- return (
- "Paperclip stopped before launching the local adapter because the project workspace checkout could not be prepared " +
- `(for example the repository clone failed).${failureSummary ?? ""} ` +
- "Moving it to `blocked` with a source-scoped recovery action so the repository URL, clone access, or configured local cwd can be repaired before resuming."
- );
- }
- return (
- "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " +
- `This prevents git-sensitive adapters from running in an unrelated fallback cwd.${failureSummary ?? ""} ` +
- "Moving it to `blocked` with a source-scoped recovery action so the workspace link, cwd, or git checkout can be repaired before resuming."
- );
- }
-
- function buildConfigurationIncompleteRecoveryComment(input: {
- latestRun: Pick | null | undefined;
- }) {
- const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
- return (
- "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
- `Resolving them as a runtime failure would only produce repeated opaque setup failures.${failureSummary ?? ""} ` +
- "Moving it to `blocked` with a source-scoped recovery action so an operator can bind the missing secret(s) before resuming."
- );
- }
-
- function buildExecutionReviewParticipantRecoveryComment(input: {
- latestRun: Pick | null | undefined;
- }) {
- const failureSummary = summarizeRunFailureForIssueComment(input.latestRun);
- return (
- "Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " +
- `or live reviewer run.${failureSummary ?? ""} ` +
- "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " +
- "restore the review stage, or record an intentional manual resolution."
- );
- }
-
async function releaseIssueExecutionAndPromote(
run: typeof heartbeatRuns.$inferSelect,
options: { suppressImmediateRecovery?: boolean } = {},
@@ -16579,9 +16494,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
kind: "blocked" as const,
issue,
previousStatus: issue.status,
- comment: configurationIncomplete
- ? buildConfigurationIncompleteRecoveryComment({ latestRun: run })
- : buildWorkspaceValidationRecoveryComment({ latestRun: run }),
+ notice: configurationIncomplete
+ ? buildConfigurationIncompleteRecoveryNoticeSeed()
+ : buildWorkspaceValidationRecoveryNoticeSeed(),
recoveryCause: configurationIncomplete
? CONFIGURATION_INCOMPLETE_RECOVERY_CAUSE
: WORKSPACE_VALIDATION_RECOVERY_CAUSE,
@@ -16925,7 +16840,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
kind: "blocked" as const,
issue,
previousStatus: issue.status,
- comment: buildExecutionReviewParticipantRecoveryComment({ latestRun: run }),
+ notice: buildExecutionReviewParticipantRecoveryNoticeSeed(),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_CAUSE,
recoveryOwnerAgentId: currentParticipant.agentId,
};
@@ -17046,19 +16961,18 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
if (shouldBlockImmediately) {
const workspaceValidationFailure = isWorkspaceValidationFailedRun(run);
const configurationIncompleteFailure = isConfigurationIncompleteFailedRun(run);
- const comment = workspaceValidationFailure
- ? buildWorkspaceValidationRecoveryComment({ latestRun: run })
+ const notice = workspaceValidationFailure
+ ? buildWorkspaceValidationRecoveryNoticeSeed()
: configurationIncompleteFailure
- ? buildConfigurationIncompleteRecoveryComment({ latestRun: run })
- : buildImmediateExecutionPathRecoveryComment({
+ ? buildConfigurationIncompleteRecoveryNoticeSeed()
+ : buildImmediateExecutionPathRecoveryNoticeSeed({
status: issue.status as "todo" | "in_progress",
- latestRun: run,
});
return {
kind: "blocked" as const,
issue,
previousStatus: issue.status,
- comment,
+ notice,
recoveryCause: workspaceValidationFailure
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
: configurationIncompleteFailure
@@ -17168,7 +17082,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
issue: promotionResult.issue,
previousStatus: promotionResult.previousStatus as "todo" | "in_progress" | "in_review",
latestRun: run,
- comment: promotionResult.comment,
+ notice: promotionResult.notice,
recoveryCause:
promotionResult.recoveryCause === WORKSPACE_VALIDATION_RECOVERY_CAUSE
? WORKSPACE_VALIDATION_RECOVERY_CAUSE
diff --git a/server/src/services/recovery/index.ts b/server/src/services/recovery/index.ts
index 245aa707e0a..6d35159ed37 100644
--- a/server/src/services/recovery/index.ts
+++ b/server/src/services/recovery/index.ts
@@ -58,8 +58,22 @@ export {
findExistingFinishSuccessfulRunHandoffWake,
isSuccessfulRunHandoffValidPathSkip,
isSuccessfulRunHandoffRequiredNoticeBody,
+ noticeMetadataReferencesRecoveryAction,
} from "./successful-run-handoff.js";
export type {
SuccessfulRunHandoffNotice,
SuccessfulRunHandoffDecision,
} from "./successful-run-handoff.js";
+export {
+ DEFAULT_STRANDED_RECOVERY_NOTICE_BODY,
+ buildConfigurationIncompleteRecoveryNoticeSeed,
+ buildExecutionReviewParticipantRecoveryNoticeSeed,
+ buildExecutionReviewParticipantUnavailableNoticeSeed,
+ buildImmediateExecutionPathRecoveryNoticeSeed,
+ buildStrandedRecoveryEscalationNotice,
+ buildWorkspaceValidationRecoveryNoticeSeed,
+} from "./stranded-notice.js";
+export type {
+ StrandedRecoveryEscalationNotice,
+ StrandedRecoveryNoticeSeed,
+} from "./stranded-notice.js";
diff --git a/server/src/services/recovery/notice-format.ts b/server/src/services/recovery/notice-format.ts
new file mode 100644
index 00000000000..520be52ec98
--- /dev/null
+++ b/server/src/services/recovery/notice-format.ts
@@ -0,0 +1,68 @@
+import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared";
+
+export type NoticeMetadataRow = IssueCommentMetadata["sections"][number]["rows"][number];
+export type NoticeMetadataSection = IssueCommentMetadata["sections"][number];
+
+export function metadataText(value: unknown, fallback = "unknown") {
+ const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
+ const resolved = text.length > 0 ? text : fallback;
+ return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved;
+}
+
+export function keyValueRow(label: string, value: unknown): NoticeMetadataRow {
+ return { type: "key_value", label, value: metadataText(value) };
+}
+
+export function issueLinkRow(
+ label: string,
+ issue: { id: string; identifier: string | null; title: string } | null | undefined,
+): NoticeMetadataRow {
+ if (!issue) return keyValueRow(label, "unknown");
+ return {
+ type: "issue_link",
+ label,
+ issueId: issue.id,
+ identifier: issue.identifier,
+ title: issue.title,
+ };
+}
+
+export function runLinkRow(
+ label: string,
+ run: { id: string; status: string; agentId?: string | null } | null | undefined,
+): NoticeMetadataRow {
+ if (!run) return keyValueRow(label, "unknown");
+ return {
+ type: "run_link",
+ label,
+ runId: run.id,
+ ...(run.agentId ? { agentId: run.agentId } : {}),
+ title: run.status,
+ };
+}
+
+export function agentLinkRow(
+ label: string,
+ agent: { id: string; name: string | null } | null | undefined,
+): NoticeMetadataRow {
+ if (!agent) return keyValueRow(label, "unknown");
+ return {
+ type: "agent_link",
+ label,
+ agentId: agent.id,
+ // Issue-comment metadata constrains link names to 160 characters.
+ name: agent.name?.slice(0, 160) ?? null,
+ };
+}
+
+export function systemNoticePresentation(input: {
+ tone: IssueCommentPresentation["tone"];
+ title: string;
+}): IssueCommentPresentation {
+ return {
+ kind: "system_notice",
+ tone: input.tone,
+ title: input.title,
+ detailsDefaultOpen: false,
+ };
+}
diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts
index c0f2a5e0326..9cfe3921970 100644
--- a/server/src/services/recovery/service.ts
+++ b/server/src/services/recovery/service.ts
@@ -61,6 +61,12 @@ import {
noticeMetadataReferencesRecoveryAction,
type SuccessfulRunHandoffNotice,
} from "./successful-run-handoff.js";
+import {
+ buildExecutionReviewParticipantRecoveryNoticeSeed,
+ buildExecutionReviewParticipantUnavailableNoticeSeed,
+ buildStrandedRecoveryEscalationNotice,
+ type StrandedRecoveryNoticeSeed,
+} from "./stranded-notice.js";
import {
RECOVERY_ORIGIN_KINDS,
buildIssueGraphLivenessLeafKey,
@@ -312,25 +318,6 @@ function summarizeRunFailureForIssueComment(run: LatestIssueRun) {
return null;
}
-function buildExecutionReviewParticipantRecoveryComment(latestRun: LatestIssueRun) {
- const failureSummary = summarizeRunFailureForIssueComment(latestRun);
- return (
- "Paperclip retried the pending execution-review participant once, but the review stage still has no completed decision " +
- `or live reviewer run.${failureSummary ?? ""} ` +
- "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " +
- "restore the review stage, or record an intentional manual resolution."
- );
-}
-
-function buildExecutionReviewParticipantUnavailableComment(latestRun: LatestIssueRun) {
- const failureSummary = summarizeRunFailureForIssueComment(latestRun);
- return (
- "Paperclip cannot continue the pending execution-review participant because the participant is not invokable " +
- `and the review stage has no completed decision or live reviewer run.${failureSummary ?? ""} ` +
- "Moving it to `blocked` with a source-scoped recovery action so the recovery owner can repair the reviewer runtime, " +
- "restore the review stage, or record an intentional manual resolution."
- );
-}
function didAutomaticRecoveryFail(
latestRun: LatestIssueRun,
@@ -3315,6 +3302,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
previousStatus: StrandedPreviousStatus;
latestRun: LatestIssueRun;
comment?: string;
+ notice?: StrandedRecoveryNoticeSeed | null;
recoveryCause?: StrandedRecoveryCause;
recoveryOwnerAgentId?: string | null;
successfulRunHandoffEvidence?: SuccessfulRunHandoffRecoveryEvidence | null;
@@ -3356,17 +3344,30 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
if (!updated) return null;
if (isProviderQuotaWait) return updated;
- const prefix = await getCompanyIssuePrefix(input.issue.companyId);
const recoveryOwner = recoveryAction.ownerAgentId ? await getAgent(recoveryAction.ownerAgentId) : null;
const sourceAssignee = input.issue.assigneeAgentId ? await getAgent(input.issue.assigneeAgentId) : null;
let notice: SuccessfulRunHandoffNotice | null = null;
if (input.recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON && input.successfulRunHandoffEvidence) {
+ const [sourceRun] = input.successfulRunHandoffEvidence.sourceRunId
+ ? await db
+ .select({
+ id: heartbeatRuns.id,
+ status: heartbeatRuns.status,
+ agentId: heartbeatRuns.agentId,
+ })
+ .from(heartbeatRuns)
+ .where(and(
+ eq(heartbeatRuns.id, input.successfulRunHandoffEvidence.sourceRunId),
+ eq(heartbeatRuns.companyId, input.issue.companyId),
+ ))
+ .limit(1)
+ : [];
notice = buildSuccessfulRunHandoffExhaustedNotice({
issue: input.issue,
- sourceRun: input.successfulRunHandoffEvidence.sourceRunId
- ? { id: input.successfulRunHandoffEvidence.sourceRunId, status: "succeeded" }
+ sourceRun: sourceRun ?? null,
+ correctiveRun: input.latestRun
+ ? { id: input.latestRun.id, status: input.latestRun.status, agentId: input.latestRun.agentId }
: null,
- correctiveRun: input.latestRun ? { id: input.latestRun.id, status: input.latestRun.status } : null,
sourceAssignee,
recoveryIssue: null,
recoveryActionId: recoveryAction.id,
@@ -3376,19 +3377,24 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
missingDisposition: input.successfulRunHandoffEvidence.missingDisposition,
});
}
- const recoveryLine = recoveryAction.ownerAgentId
- ? [
- "",
- `- Recovery action: \`${recoveryAction.id}\``,
- `- Recovery owner: ${agentUiLink(recoveryOwner, prefix)}`,
- "- Next action: the recovery owner should either restore a live execution path or record the manual resolution on the source issue.",
- ].join("\n")
- : [
- "",
- `- Recovery action: \`${recoveryAction.id}\``,
- "- Recovery owner: board escalation, because Paperclip could not find an invokable manager, creator, or executive owner with budget available.",
- "- Next action: a board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution.",
- ].join("\n");
+ const escalationNotice = buildStrandedRecoveryEscalationNotice({
+ seed: input.notice,
+ fallbackBody: input.comment,
+ recoveryCause,
+ recoveryActionId: recoveryAction.id,
+ recoveryOwner: recoveryAction.ownerAgentId && recoveryOwner
+ ? { id: recoveryOwner.id, name: recoveryOwner.name }
+ : null,
+ sourceRun: input.latestRun
+ ? {
+ id: input.latestRun.id,
+ agentId: input.latestRun.agentId,
+ status: input.latestRun.status,
+ errorCode: input.latestRun.errorCode,
+ errorSummary: input.latestRun.error ? redactSensitiveText(input.latestRun.error) : null,
+ }
+ : null,
+ });
const shouldPostEscalationComment =
recoveryAction.attemptCount === 1 ||
@@ -3421,19 +3427,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
metadata: notice.metadata,
});
} else {
- await issuesSvc.addComment(input.issue.id, `${input.comment ?? ""}${recoveryLine}`, {}, {
+ await issuesSvc.addComment(input.issue.id, escalationNotice.body, {}, {
authorType: "system",
- presentation: compactRecoveryPresentation(
- `Recovery: ${recoveryCauseTitle(recoveryCause)} — moved to blocked ` +
- `(owner: ${recoveryOwner?.name ?? "board"})`,
- ),
- metadata: recoveryNoticeMetadata({
- cause: recoveryCause,
- latestRun: input.latestRun,
- recoveryActionId: recoveryAction.id,
- previousStatus: input.previousStatus,
- recoveryOwner,
- }),
+ presentation: escalationNotice.presentation,
+ metadata: escalationNotice.metadata,
});
}
}
@@ -3907,7 +3904,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue,
previousStatus: "in_review",
latestRun: participantLatestRun,
- comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun),
+ notice: buildExecutionReviewParticipantUnavailableNoticeSeed(),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
recoveryOwnerAgentId: participantAgentId,
});
@@ -3974,7 +3971,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue,
previousStatus: "in_review",
latestRun: participantLatestRun,
- comment: buildExecutionReviewParticipantUnavailableComment(participantLatestRun),
+ notice: buildExecutionReviewParticipantUnavailableNoticeSeed(),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
recoveryOwnerAgentId: participantAgentId,
});
@@ -3992,7 +3989,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
issue,
previousStatus: "in_review",
latestRun: participantLatestRun,
- comment: buildExecutionReviewParticipantRecoveryComment(participantLatestRun),
+ notice: buildExecutionReviewParticipantRecoveryNoticeSeed(),
recoveryCause: EXECUTION_REVIEW_PARTICIPANT_RECOVERY_REASON,
recoveryOwnerAgentId: participantAgentId,
});
@@ -4069,15 +4066,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
}
if (didAutomaticRecoveryFail(latestRun, "assignment_recovery")) {
- const failureSummary = summarizeRunFailureForIssueComment(latestRun);
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "todo",
latestRun,
- comment:
- "Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " +
- `but it still has no live execution path.${failureSummary ?? ""} ` +
- "Moving it to `blocked` so it is visible for intervention.",
+ notice: {
+ body:
+ "Paperclip automatically retried dispatch for this assigned `todo` issue after a lost wake/run, " +
+ "but it still has no live execution path. " +
+ "Moving it to `blocked` so it is visible for intervention.",
+ title: "No live execution path",
+ tone: "danger",
+ },
});
if (updated) {
result.escalated += 1;
@@ -4210,15 +4210,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
}
if (classification.kind === "non_retryable") {
- const failureSummary = summarizeRunFailureForIssueComment(latestRun);
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
- comment:
- "Paperclip detected a non-retryable failure on this issue's continuation run " +
- `(\`${classification.errorCode}\`). Skipping automatic retries and moving it to \`blocked\` ` +
- `so it is visible for intervention.${failureSummary ?? ""}`,
+ notice: {
+ body:
+ "Paperclip detected a non-retryable failure on this issue's continuation run " +
+ `(\`${classification.errorCode}\`). Skipping automatic retries and moving it to \`blocked\` ` +
+ "so it is visible for intervention.",
+ title: "Continuation failed",
+ tone: "danger",
+ },
});
if (updated) {
result.escalated += 1;
@@ -4237,19 +4240,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
classification.errorCode,
);
if (consecutive >= classification.maxAttempts) {
- const failureSummary = summarizeRunFailureForIssueComment(latestRun);
const attemptCopy = consecutive <= 1 ? "" : ` (${consecutive}× attempts)`;
- const causeCopy = classification.errorCode
- ? ` Latest cause: \`${classification.errorCode}\`.`
- : "";
const updated = await escalateStrandedAssignedIssue({
issue,
previousStatus: "in_progress",
latestRun,
- comment:
- "Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " +
- `execution disappeared, but it still has no live execution path${attemptCopy}.${causeCopy}${failureSummary ?? ""} ` +
- "Moving it to `blocked` so it is visible for intervention.",
+ notice: {
+ body:
+ "Paperclip automatically retried continuation for this assigned `in_progress` issue after its live " +
+ `execution disappeared, but it still has no live execution path${attemptCopy}. ` +
+ "Moving it to `blocked` so it is visible for intervention.",
+ title: "No live execution path",
+ tone: "danger",
+ },
});
if (updated) {
result.escalated += 1;
diff --git a/server/src/services/recovery/stranded-notice.test.ts b/server/src/services/recovery/stranded-notice.test.ts
new file mode 100644
index 00000000000..3fff23b5fcd
--- /dev/null
+++ b/server/src/services/recovery/stranded-notice.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest";
+import { noticeMetadataReferencesRecoveryAction } from "./successful-run-handoff.js";
+import {
+ DEFAULT_STRANDED_RECOVERY_NOTICE_BODY,
+ buildConfigurationIncompleteRecoveryNoticeSeed,
+ buildExecutionReviewParticipantRecoveryNoticeSeed,
+ buildExecutionReviewParticipantUnavailableNoticeSeed,
+ buildImmediateExecutionPathRecoveryNoticeSeed,
+ buildStrandedRecoveryEscalationNotice,
+ buildWorkspaceValidationRecoveryNoticeSeed,
+} from "./stranded-notice.js";
+
+function allRows(metadata: { sections: Array<{ rows: unknown[] }> }) {
+ return metadata.sections.flatMap((section) => section.rows) as Array>;
+}
+
+describe("stranded recovery notice seeds", () => {
+ it.each([
+ ["todo dispatch", buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }), "No live execution path"],
+ ["in_progress continuation", buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }), "No live execution path"],
+ ["workspace validation", buildWorkspaceValidationRecoveryNoticeSeed(), "Workspace validation failed"],
+ ["configuration incomplete", buildConfigurationIncompleteRecoveryNoticeSeed(), "Configuration incomplete"],
+ ["review participant recovery", buildExecutionReviewParticipantRecoveryNoticeSeed(), "Review recovery stalled"],
+ ["review participant unavailable", buildExecutionReviewParticipantUnavailableNoticeSeed(), "Review recovery stalled"],
+ ])("%s seed has a short body plus title and tone", (_label, seed, expectedTitle) => {
+ expect(seed.title).toBe(expectedTitle);
+ expect(seed.tone).toBe("danger");
+ expect(seed.body.length).toBeGreaterThan(0);
+ // Failure details now live in metadata rows, never inline in the body.
+ expect(seed.body).not.toContain("Latest retry failure");
+ expect(seed.body).not.toContain("Recovery action:");
+ });
+
+ it("distinguishes todo dispatch from in_progress continuation copy", () => {
+ expect(buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }).body).toContain("retried dispatch");
+ expect(buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }).body).toContain(
+ "retried continuation",
+ );
+ });
+});
+
+describe("buildStrandedRecoveryEscalationNotice", () => {
+ const actionId = "6a2f8e64-6f5e-4b58-b7fd-111111111111";
+ const owner = { id: "9b1c2d3e-4f50-4a61-8b72-222222222222", name: "CTO" };
+ const sourceRun = {
+ id: "0c1d2e3f-4a5b-4c6d-8e7f-333333333333",
+ agentId: "4d5e6f70-8a9b-4c1d-8e2f-444444444444",
+ status: "failed",
+ errorCode: "workspace_validation_failed",
+ errorSummary: "Expected project worktree but resolved the agent fallback directory.",
+ };
+
+ it("emits system_notice presentation and the required metadata rows", () => {
+ const seed = buildWorkspaceValidationRecoveryNoticeSeed();
+ const notice = buildStrandedRecoveryEscalationNotice({
+ seed,
+ recoveryActionId: actionId,
+ recoveryOwner: owner,
+ sourceRun,
+ });
+
+ expect(notice.body).toBe(seed.body);
+ expect(notice.presentation).toMatchObject({
+ kind: "system_notice",
+ tone: "danger",
+ title: "Workspace validation failed",
+ });
+ expect(notice.metadata.version).toBe(1);
+ expect(notice.metadata.sourceRunId).toBe(sourceRun.id);
+
+ const rows = allRows(notice.metadata);
+ expect(rows).toContainEqual({ type: "key_value", label: "Recovery action", value: actionId });
+ expect(rows).toContainEqual({ type: "agent_link", label: "Recovery owner", agentId: owner.id, name: owner.name });
+ expect(rows).toContainEqual({
+ type: "run_link",
+ label: "Source run",
+ runId: sourceRun.id,
+ agentId: sourceRun.agentId,
+ title: "failed",
+ });
+ expect(rows).toContainEqual({
+ type: "key_value",
+ label: "Failure code",
+ value: "workspace_validation_failed",
+ });
+ expect(rows).toContainEqual({
+ type: "key_value",
+ label: "Failure summary",
+ value: sourceRun.errorSummary,
+ });
+ expect(rows.some((row) => row.label === "Next action")).toBe(true);
+ });
+
+ it("records board escalation when there is no invokable recovery owner", () => {
+ const notice = buildStrandedRecoveryEscalationNotice({
+ seed: buildConfigurationIncompleteRecoveryNoticeSeed(),
+ recoveryActionId: actionId,
+ recoveryOwner: null,
+ sourceRun,
+ });
+
+ const ownerRow = allRows(notice.metadata).find((row) => row.label === "Recovery owner");
+ expect(ownerRow?.type).toBe("key_value");
+ expect(String(ownerRow?.value)).toContain("Board escalation");
+ });
+
+ it("derives title from the recovery cause and body from the plain-comment fallback", () => {
+ const notice = buildStrandedRecoveryEscalationNotice({
+ fallbackBody: "Legacy plain comment body.",
+ recoveryCause: "configuration_incomplete",
+ recoveryActionId: actionId,
+ recoveryOwner: owner,
+ sourceRun: null,
+ });
+
+ expect(notice.body).toBe("Legacy plain comment body.");
+ expect(notice.presentation.title).toBe("Configuration incomplete");
+ expect(notice.metadata.sourceRunId).toBeNull();
+ expect(notice.metadata.sections).toHaveLength(1);
+ });
+
+ it("falls back to the default body and title when no seed or comment is given", () => {
+ const notice = buildStrandedRecoveryEscalationNotice({
+ recoveryActionId: actionId,
+ recoveryOwner: null,
+ sourceRun: null,
+ });
+
+ expect(notice.body).toBe(DEFAULT_STRANDED_RECOVERY_NOTICE_BODY);
+ expect(notice.presentation.title).toBe("Automatic recovery blocked");
+ });
+
+ it("omits the failure code row when the source run has no error code", () => {
+ const notice = buildStrandedRecoveryEscalationNotice({
+ seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }),
+ recoveryActionId: actionId,
+ recoveryOwner: owner,
+ sourceRun: {
+ id: sourceRun.id,
+ agentId: sourceRun.agentId,
+ status: "cancelled",
+ errorCode: null,
+ errorSummary: null,
+ },
+ });
+
+ const rows = allRows(notice.metadata);
+ expect(rows).toContainEqual({
+ type: "run_link",
+ label: "Source run",
+ runId: sourceRun.id,
+ agentId: sourceRun.agentId,
+ title: "cancelled",
+ });
+ expect(rows.some((row) => row.label === "Failure code")).toBe(false);
+ expect(rows.some((row) => row.label === "Failure summary")).toBe(false);
+ });
+
+ it("is matched by the metadata-based escalation dedupe matcher", () => {
+ const notice = buildStrandedRecoveryEscalationNotice({
+ seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }),
+ recoveryActionId: actionId,
+ recoveryOwner: owner,
+ sourceRun,
+ });
+
+ // The escalation dedupe in recovery/service.ts no longer relies on the
+ // `Recovery action: \`id\`` body marker for new comments; the metadata
+ // matcher must recognize every notice this builder produces.
+ expect(notice.body).not.toContain(actionId);
+ expect(noticeMetadataReferencesRecoveryAction(notice.metadata, actionId)).toBe(true);
+ expect(
+ noticeMetadataReferencesRecoveryAction(notice.metadata, "1f2e3d4c-5b6a-4798-8899-444444444444"),
+ ).toBe(false);
+ });
+});
diff --git a/server/src/services/recovery/stranded-notice.ts b/server/src/services/recovery/stranded-notice.ts
new file mode 100644
index 00000000000..7705ba328f3
--- /dev/null
+++ b/server/src/services/recovery/stranded-notice.ts
@@ -0,0 +1,158 @@
+import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared";
+import {
+ agentLinkRow,
+ keyValueRow,
+ runLinkRow,
+ systemNoticePresentation,
+ type NoticeMetadataRow,
+ type NoticeMetadataSection,
+} from "./notice-format.js";
+
+// Short human-readable body plus the presentation header for one recovery
+// family. The escalation path merges in the metadata rows only it knows
+// (recovery action, owner, source run) via buildStrandedRecoveryEscalationNotice.
+export type StrandedRecoveryNoticeSeed = {
+ body: string;
+ title: string;
+ tone: IssueCommentPresentation["tone"];
+};
+
+export type StrandedRecoveryEscalationNotice = {
+ body: string;
+ presentation: IssueCommentPresentation;
+ metadata: IssueCommentMetadata;
+};
+
+export const DEFAULT_STRANDED_RECOVERY_NOTICE_BODY =
+ "Paperclip could not restore a live execution path for this issue automatically. " +
+ "Moving it to `blocked` so it is visible for intervention.";
+
+const DEFAULT_STRANDED_RECOVERY_NOTICE_TITLE = "Automatic recovery blocked";
+
+const STRANDED_RECOVERY_NOTICE_TITLES_BY_CAUSE: Record = {
+ workspace_validation_failed: "Workspace validation failed",
+ configuration_incomplete: "Configuration incomplete",
+ execution_review_participant_recovery: "Review recovery stalled",
+};
+
+export function buildImmediateExecutionPathRecoveryNoticeSeed(input: {
+ status: "todo" | "in_progress";
+}): StrandedRecoveryNoticeSeed {
+ const retryDescription = input.status === "todo"
+ ? "Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery"
+ : "Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run recovery";
+ return {
+ body:
+ `${retryDescription}, but it still has no live execution path. ` +
+ "Moving it to `blocked` so it is visible for intervention.",
+ title: "No live execution path",
+ tone: "danger",
+ };
+}
+
+export function buildWorkspaceValidationRecoveryNoticeSeed(): StrandedRecoveryNoticeSeed {
+ return {
+ body:
+ "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " +
+ "Moving it to `blocked` so the workspace link, cwd, or git checkout can be repaired before resuming.",
+ title: "Workspace validation failed",
+ tone: "danger",
+ };
+}
+
+export function buildConfigurationIncompleteRecoveryNoticeSeed(): StrandedRecoveryNoticeSeed {
+ return {
+ body:
+ "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
+ "Moving it to `blocked` so an operator can bind the missing secret(s) before resuming.",
+ title: "Configuration incomplete",
+ tone: "danger",
+ };
+}
+
+export function buildExecutionReviewParticipantRecoveryNoticeSeed(): StrandedRecoveryNoticeSeed {
+ return {
+ body:
+ "Paperclip retried the pending execution-review participant once, but the review stage still has no " +
+ "completed decision or live reviewer run. Moving it to `blocked` so the recovery owner can repair the " +
+ "reviewer runtime, restore the review stage, or record an intentional manual resolution.",
+ title: "Review recovery stalled",
+ tone: "danger",
+ };
+}
+
+export function buildExecutionReviewParticipantUnavailableNoticeSeed(): StrandedRecoveryNoticeSeed {
+ return {
+ body:
+ "Paperclip cannot continue the pending execution-review participant because the participant is not " +
+ "invokable and the review stage has no completed decision or live reviewer run. Moving it to `blocked` " +
+ "so the recovery owner can repair the reviewer runtime, restore the review stage, or record an " +
+ "intentional manual resolution.",
+ title: "Review recovery stalled",
+ tone: "danger",
+ };
+}
+
+// Escalation dedupe matches the `Recovery action` key_value row via
+// noticeMetadataReferencesRecoveryAction, so this builder must always emit
+// that row with the raw action id.
+export function buildStrandedRecoveryEscalationNotice(input: {
+ seed?: StrandedRecoveryNoticeSeed | null;
+ fallbackBody?: string | null;
+ recoveryCause?: string | null;
+ recoveryActionId: string;
+ recoveryOwner: { id: string; name: string | null } | null | undefined;
+ sourceRun: {
+ id: string;
+ agentId?: string | null;
+ status: string;
+ errorCode?: string | null;
+ errorSummary?: string | null;
+ } | null | undefined;
+}): StrandedRecoveryEscalationNotice {
+ const fallbackBody = input.fallbackBody?.trim();
+ const body = input.seed?.body ?? (fallbackBody || DEFAULT_STRANDED_RECOVERY_NOTICE_BODY);
+ const title = input.seed?.title ??
+ STRANDED_RECOVERY_NOTICE_TITLES_BY_CAUSE[input.recoveryCause ?? ""] ??
+ DEFAULT_STRANDED_RECOVERY_NOTICE_TITLE;
+
+ const recoveryRows: NoticeMetadataRow[] = [
+ keyValueRow("Recovery action", input.recoveryActionId),
+ input.recoveryOwner
+ ? agentLinkRow("Recovery owner", input.recoveryOwner)
+ : keyValueRow(
+ "Recovery owner",
+ "Board escalation - Paperclip could not find an invokable manager, creator, or executive owner with budget available",
+ ),
+ keyValueRow(
+ "Next action",
+ input.recoveryOwner
+ ? "The recovery owner should either restore a live execution path or record the manual resolution on the source issue"
+ : "A board operator should assign an invokable recovery owner, fix the agent/runtime state, or record an intentional manual resolution",
+ ),
+ ];
+
+ const runRows: NoticeMetadataRow[] = [];
+ if (input.sourceRun) {
+ runRows.push(runLinkRow("Source run", input.sourceRun));
+ const failureCode = input.sourceRun.errorCode?.trim();
+ if (failureCode) runRows.push(keyValueRow("Failure code", failureCode));
+ const failureSummary = input.sourceRun.errorSummary?.trim();
+ if (failureSummary) runRows.push(keyValueRow("Failure summary", failureSummary));
+ }
+
+ const sections: NoticeMetadataSection[] = [
+ { title: "Recovery", rows: recoveryRows },
+ ...(runRows.length > 0 ? [{ title: "Run evidence", rows: runRows }] : []),
+ ];
+
+ return {
+ body,
+ presentation: systemNoticePresentation({ tone: input.seed?.tone ?? "danger", title }),
+ metadata: {
+ version: 1,
+ sourceRunId: input.sourceRun?.id ?? null,
+ sections,
+ },
+ };
+}
diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts
index 46a36c63968..5aa042b0418 100644
--- a/server/src/services/recovery/successful-run-handoff.test.ts
+++ b/server/src/services/recovery/successful-run-handoff.test.ts
@@ -389,6 +389,7 @@ describe("successful run handoff decision", () => {
run: {
id: "22222222-2222-4222-8222-222222222222",
status: "succeeded",
+ agentId: "33333333-3333-4333-8333-333333333333",
} as any,
agent: {
id: "33333333-3333-4333-8333-333333333333",
@@ -417,7 +418,11 @@ describe("successful run handoff decision", () => {
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
- expect.objectContaining({ type: "run_link", runId: "22222222-2222-4222-8222-222222222222" }),
+ expect.objectContaining({
+ type: "run_link",
+ runId: "22222222-2222-4222-8222-222222222222",
+ agentId: "33333333-3333-4333-8333-333333333333",
+ }),
expect.objectContaining({ type: "key_value", label: "Normalized cause", value: SUCCESSFUL_RUN_MISSING_STATE_REASON }),
expect.objectContaining({ type: "key_value", label: "Detected progress" }),
]),
@@ -433,8 +438,16 @@ describe("successful run handoff decision", () => {
title: "Finish backend handoff",
status: "in_progress",
} as any,
- sourceRun: { id: "22222222-2222-4222-8222-222222222222", status: "succeeded" } as any,
- correctiveRun: { id: "44444444-4444-4444-8444-444444444444", status: "failed" } as any,
+ sourceRun: {
+ id: "22222222-2222-4222-8222-222222222222",
+ status: "succeeded",
+ agentId: "33333333-3333-4333-8333-333333333333",
+ } as any,
+ correctiveRun: {
+ id: "44444444-4444-4444-8444-444444444444",
+ status: "failed",
+ agentId: "66666666-6666-4666-8666-666666666666",
+ } as any,
sourceAssignee: { id: "33333333-3333-4333-8333-333333333333", name: "CodexCoder" } as any,
recoveryIssue: {
id: "55555555-5555-4555-8555-555555555555",
@@ -467,7 +480,16 @@ describe("successful run handoff decision", () => {
expect.objectContaining({
title: "Run evidence",
rows: expect.arrayContaining([
- expect.objectContaining({ type: "run_link", label: "Source run" }),
+ expect.objectContaining({
+ type: "run_link",
+ label: "Source run",
+ agentId: "33333333-3333-4333-8333-333333333333",
+ }),
+ expect.objectContaining({
+ type: "run_link",
+ label: "Corrective handoff run",
+ agentId: "66666666-6666-4666-8666-666666666666",
+ }),
expect.objectContaining({ type: "run_link", label: "Corrective handoff run" }),
expect.objectContaining({ type: "key_value", label: "Missing disposition", value: "clear_next_step" }),
]),
diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts
index d415ccde205..e6a3533586d 100644
--- a/server/src/services/recovery/successful-run-handoff.ts
+++ b/server/src/services/recovery/successful-run-handoff.ts
@@ -3,6 +3,13 @@ import type { Db } from "@paperclipai/db";
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
import type { IssueCommentMetadata, IssueCommentPresentation, RunLivenessState } from "@paperclipai/shared";
import { withRecoveryModelProfileHint } from "./model-profile-hint.js";
+import {
+ agentLinkRow,
+ issueLinkRow,
+ keyValueRow,
+ runLinkRow,
+ systemNoticePresentation,
+} from "./notice-format.js";
export const FINISH_SUCCESSFUL_RUN_HANDOFF_REASON = "finish_successful_run_handoff";
export const SUCCESSFUL_RUN_MISSING_STATE_REASON = "successful_run_missing_state";
@@ -57,7 +64,7 @@ type IssueRow = Pick<
>;
type AgentRow = Pick;
type NoticeIssue = Pick;
-type NoticeRun = Pick;
+type NoticeRun = Pick;
type NoticeAgent = Pick;
type NullableNoticeAgent = NoticeAgent | null | undefined;
type NullableNoticeIssue = NoticeIssue | null | undefined;
@@ -115,58 +122,6 @@ export function isSuccessfulRunHandoffValidPathSkip(
return decision.kind === "skip" && SUCCESSFUL_RUN_HANDOFF_VALID_PATH_SKIP_REASONS.has(decision.reason);
}
-function metadataText(value: unknown, fallback = "unknown") {
- const text = typeof value === "string" ? value.trim() : value == null ? "" : String(value).trim();
- const resolved = text.length > 0 ? text : fallback;
- return resolved.length > 2000 ? `${resolved.slice(0, 1997)}...` : resolved;
-}
-
-function keyValueRow(label: string, value: unknown): IssueCommentMetadata["sections"][number]["rows"][number] {
- return { type: "key_value", label, value: metadataText(value) };
-}
-
-function issueLinkRow(
- label: string,
- issue: NullableNoticeIssue,
-): IssueCommentMetadata["sections"][number]["rows"][number] {
- if (!issue) return keyValueRow(label, "unknown");
- return {
- type: "issue_link",
- label,
- issueId: issue.id,
- identifier: issue.identifier,
- title: issue.title,
- };
-}
-
-function runLinkRow(
- label: string,
- run: NullableNoticeRun,
-): IssueCommentMetadata["sections"][number]["rows"][number] {
- if (!run) return keyValueRow(label, "unknown");
- return { type: "run_link", label, runId: run.id, title: run.status };
-}
-
-function agentLinkRow(
- label: string,
- agent: NullableNoticeAgent,
-): IssueCommentMetadata["sections"][number]["rows"][number] {
- if (!agent) return keyValueRow(label, "unknown");
- return { type: "agent_link", label, agentId: agent.id, name: agent.name };
-}
-
-function systemNoticePresentation(input: {
- tone: IssueCommentPresentation["tone"];
- title: string;
-}): IssueCommentPresentation {
- return {
- kind: "system_notice",
- tone: input.tone,
- title: input.title,
- detailsDefaultOpen: false,
- };
-}
-
export function isSuccessfulRunHandoffRequiredNoticeBody(body: string) {
const trimmed = body.trim();
return trimmed === SUCCESSFUL_RUN_HANDOFF_REQUIRED_NOTICE_BODY ||
diff --git a/ui/src/components/SystemNotice.tsx b/ui/src/components/SystemNotice.tsx
index 343c397694d..291e380d262 100644
--- a/ui/src/components/SystemNotice.tsx
+++ b/ui/src/components/SystemNotice.tsx
@@ -206,6 +206,38 @@ function MetadataRow({ row, tone }: { row: SystemNoticeMetadataRow; tone: ToneTo
);
}
+/**
+ * The structured metadata rows shared by the boxed SystemNotice details panel
+ * and the chat shell's expandable system row (TaskChatSystemNotice, PAP-443).
+ */
+export function SystemNoticeMetadataSections({
+ sections,
+ tone = "neutral",
+}: {
+ sections: SystemNoticeMetadataSection[];
+ tone?: SystemNoticeTone;
+}) {
+ const tokens = TONE_TOKENS[tone];
+ return (
+
+ {sections.map((section, sectionIdx) => (
+
+ {section.title ? (
+
+ {section.title}
+
+ ) : null}
+
+ {section.rows.map((row, rowIdx) => (
+
+ ))}
+
+
+ ))}
+
+ );
+}
+
export function SystemNotice({
tone = "neutral",
label,
@@ -312,22 +344,7 @@ export function SystemNotice({
tokens.divider,
)}
>
-
- {metadata!.map((section, sectionIdx) => (
-
- {section.title ? (
-
- {section.title}
-
- ) : null}
-
- {section.rows.map((row, rowIdx) => (
-
- ))}
-
-
- ))}
-
+
) : null}
diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx
index 528fbc985c9..89584df89f9 100644
--- a/ui/src/components/task-chat/TaskChatBubble.tsx
+++ b/ui/src/components/task-chat/TaskChatBubble.tsx
@@ -14,6 +14,7 @@ import {
AttachmentTrigger,
} from "@/components/ui/attachment";
import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments";
+import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
import type { TaskChatMessageItem } from "./task-chat-model";
interface TaskChatBubbleProps {
@@ -58,11 +59,8 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr
}
if (item.author === "system") {
- return (
-
- );
+ // Collapsed humanized one-liner, expandable to the full detail (PAP-443).
+ return ;
}
const isHuman = item.author === "human";
diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx
new file mode 100644
index 00000000000..6d34d7b45ea
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx
@@ -0,0 +1,123 @@
+// @vitest-environment jsdom
+
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { ThemeProvider } from "@/context/ThemeContext";
+import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
+import type { TaskChatMessageItem } from "./task-chat-model";
+
+describe("TaskChatSystemNotice (PAP-443)", () => {
+ let container: HTMLDivElement;
+ let root: Root | null = null;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ flushSync(() => root?.unmount());
+ root = null;
+ container.remove();
+ });
+
+ const recoveryBody =
+ "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
+ "Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action.";
+
+ function renderNotice(overrides: Partial = {}) {
+ const item: TaskChatMessageItem = {
+ id: "sys-1",
+ kind: "message",
+ author: "system",
+ text: recoveryBody,
+ createdAtIso: new Date(Date.now() - 5 * 60_000).toISOString(),
+ ...overrides,
+ };
+ flushSync(() =>
+ root!.render(
+
+
+ ,
+ ),
+ );
+ }
+
+ function toggleButton() {
+ return container.querySelector(
+ '[data-testid="task-chat-system-notice"] button',
+ )!;
+ }
+
+ it("collapses to a humanized one-liner with relative time and hides the raw body", () => {
+ renderNotice();
+ const button = toggleButton();
+ expect(button.getAttribute("aria-expanded")).toBe("false");
+ expect(button.textContent).toContain("Task paused — a secret/config binding is missing");
+ expect(button.textContent).toContain("5m ago");
+ expect(container.textContent).not.toContain("source-scoped recovery action");
+ expect(
+ container.querySelector('[data-testid="task-chat-system-notice-details"]'),
+ ).toBeNull();
+ });
+
+ it("expands on click to the full markdown body and metadata sections", () => {
+ renderNotice({
+ metadata: {
+ version: 1,
+ sections: [
+ { title: "Failure", rows: [{ type: "code", label: "Code", code: "configuration_incomplete" }] },
+ ],
+ },
+ });
+ flushSync(() => toggleButton().click());
+
+ expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
+ const details = container.querySelector('[data-testid="task-chat-system-notice-details"]');
+ expect(details).not.toBeNull();
+ expect(details!.textContent).toContain("required secret/env bindings are missing");
+ expect(details!.textContent).toContain("Failure");
+ expect(details!.textContent).toContain("configuration_incomplete");
+
+ // Collapses back — presentation-only fold, nothing lost.
+ flushSync(() => toggleButton().click());
+ expect(
+ container.querySelector('[data-testid="task-chat-system-notice-details"]'),
+ ).toBeNull();
+ });
+
+ it("links source-run metadata when the comment carries its run agent", () => {
+ renderNotice({
+ metadata: {
+ version: 1,
+ sections: [
+ {
+ title: "Run",
+ rows: [{ type: "run_link", label: "Source run", runId: "run-1", agentId: "agent-1", title: "failed" }],
+ },
+ ],
+ },
+ });
+ flushSync(() => toggleButton().click());
+
+ expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).not.toBeNull();
+ });
+
+ it("respects presentation.detailsDefaultOpen", () => {
+ renderNotice({
+ presentation: {
+ kind: "system_notice",
+ tone: "warning",
+ title: "Run recovery",
+ detailsDefaultOpen: true,
+ },
+ });
+ expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
+ expect(toggleButton().textContent).toContain("Run recovery");
+ expect(
+ container.querySelector('[data-testid="task-chat-system-notice-details"]'),
+ ).not.toBeNull();
+ });
+});
diff --git a/ui/src/components/task-chat/TaskChatSystemNotice.tsx b/ui/src/components/task-chat/TaskChatSystemNotice.tsx
new file mode 100644
index 00000000000..da9c40bd1bf
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatSystemNotice.tsx
@@ -0,0 +1,100 @@
+import { useId, useState } from "react";
+import {
+ ChevronDown,
+ CircleCheck,
+ Info,
+ OctagonAlert,
+ TriangleAlert,
+ type LucideIcon,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { MarkdownBody } from "@/components/MarkdownBody";
+import {
+ SystemNoticeMetadataSections,
+ type SystemNoticeTone,
+} from "@/components/SystemNotice";
+import { humanizeSystemNotice } from "@/lib/system-notice-humanizer";
+import { mapCommentMetadataToSystemNoticeSections } from "@/lib/system-notice-comment";
+import { timeAgo } from "@/lib/timeAgo";
+import type { TaskChatMessageItem } from "./task-chat-model";
+
+const TONE_ICON: Record = {
+ neutral: Info,
+ info: Info,
+ success: CircleCheck,
+ warning: TriangleAlert,
+ danger: OctagonAlert,
+};
+
+const TONE_ICON_CLASS: Record = {
+ neutral: "text-muted-foreground",
+ info: "text-(--status-task-icon-in_progress)",
+ success: "text-(--status-task-icon-done)",
+ warning: "text-(--status-task-icon-todo)",
+ danger: "text-(--status-task-icon-blocked)",
+};
+
+/**
+ * System comment row in the chat shell (PAP-443 / PAP-442 Phase 1): collapsed,
+ * it reads as a quiet centered one-liner in TaskChatMarker's register — tone
+ * icon + humanized plain-English title + relative time + chevron — instead of
+ * a large gray paragraph of raw text. Expanding reveals the full
+ * markdown-rendered body plus any structured metadata sections; nothing is
+ * suppressed, only folded.
+ */
+export function TaskChatSystemNotice({ item }: { item: TaskChatMessageItem }) {
+ const [open, setOpen] = useState(Boolean(item.presentation?.detailsDefaultOpen));
+ const detailsId = useId();
+ const { title, tone, detail } = humanizeSystemNotice({
+ body: item.text,
+ presentation: item.presentation,
+ });
+ const sections = mapCommentMetadataToSystemNoticeSections(item.metadata, {
+ runAgentId: item.runAgentId,
+ });
+ const ToneIcon = TONE_ICON[tone];
+ const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
+
+ return (
+
+
setOpen((v) => !v)}
+ aria-expanded={open}
+ aria-controls={detailsId}
+ className={cn(
+ "flex max-w-(--pct-85) items-center gap-1.5 rounded-md px-2 py-0.5 text-xs text-muted-foreground",
+ "hover:bg-muted/50 hover:text-foreground",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
+ )}
+ >
+
+ {title}
+ {detail ? · {detail} : null}
+ {relative ? · {relative} : null}
+
+
+ {open ? (
+
+
+
+ {item.text}
+
+
+ {sections.length > 0 ? (
+
+
+
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/components/task-chat/task-chat-adapter.test.ts b/ui/src/components/task-chat/task-chat-adapter.test.ts
index 3af1b5b7074..27e0ad3b536 100644
--- a/ui/src/components/task-chat/task-chat-adapter.test.ts
+++ b/ui/src/components/task-chat/task-chat-adapter.test.ts
@@ -22,4 +22,67 @@ describe("commentsToTaskChatItems", () => {
expect(item.author).toBe("agent");
expect(item.interstitial).toBeUndefined();
});
+
+ it("classifies system comments as system even with a derivable run→agent linkage (PAP-443)", () => {
+ const comments = [
+ {
+ id: "c-sys",
+ body: "Paperclip automatically retried dispatch, but it still has no live execution path.",
+ authorType: "system",
+ authorAgentId: null,
+ derivedAuthorAgentId: "agent-1",
+ createdAt: "2026-08-07T09:00:00.000Z",
+ } as unknown as IssueChatComment,
+ ];
+ const items = commentsToTaskChatItems(comments);
+ expect(items).toHaveLength(1);
+ const item = items[0];
+ if (item.kind !== "message") throw new Error("expected message item");
+ expect(item.author).toBe("system");
+ });
+
+ it("carries presentation, metadata, and the raw timestamp for system comments", () => {
+ const presentation = {
+ kind: "system_notice",
+ tone: "warning",
+ title: "Run recovery",
+ detailsDefaultOpen: true,
+ };
+ const metadata = {
+ version: 1,
+ sections: [{ rows: [{ type: "text", label: "Reason", text: "quota" }] }],
+ };
+ const comments = [
+ {
+ id: "c-sys",
+ body: "Recovery notice.",
+ authorType: "system",
+ presentation,
+ metadata,
+ runAgentId: "agent-1",
+ createdAt: new Date("2026-08-07T09:00:00.000Z"),
+ } as unknown as IssueChatComment,
+ {
+ id: "c-agent",
+ body: "Agent reply.",
+ authorType: "agent",
+ authorAgentId: "agent-1",
+ presentation: null,
+ metadata,
+ createdAt: "2026-08-07T09:01:00.000Z",
+ } as unknown as IssueChatComment,
+ ];
+ const items = commentsToTaskChatItems(comments);
+ const [sys, agent] = items;
+ if (sys.kind !== "message" || agent.kind !== "message") throw new Error("expected messages");
+ expect(sys.presentation).toEqual(presentation);
+ expect(sys.metadata).toEqual(metadata);
+ expect(sys.runAgentId).toBe("agent-1");
+ expect(sys.createdAtIso).toBe("2026-08-07T09:00:00.000Z");
+ // Non-system authors keep the item lean — no structured notice fields.
+ expect(agent.presentation).toBeUndefined();
+ expect(agent.metadata).toBeUndefined();
+ expect(agent.runAgentId).toBeUndefined();
+ expect(agent.createdAtIso).toBeUndefined();
+ });
});
diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts
index 552b5099ccd..db3edebbf7a 100644
--- a/ui/src/components/task-chat/task-chat-adapter.ts
+++ b/ui/src/components/task-chat/task-chat-adapter.ts
@@ -34,10 +34,13 @@ function effectiveAgentId(comment: IssueChatComment): string | null {
}
function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
+ // System authorship wins over any derivable run→agent linkage (PAP-443):
+ // recovery notices carry a derivedAuthorAgentId but must not render as
+ // agent bubbles.
+ if (comment.authorType === "system") return "system";
if (effectiveAgentId(comment)) return "agent";
if (comment.authorType === "user") return "human";
- if (comment.authorType === "agent") return "agent";
- return "system";
+ return "agent";
}
/** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */
@@ -79,6 +82,12 @@ export function commentsToTaskChatItems(
: comment.clientStatus === "pending"
? "pending"
: undefined;
+ const createdAtIso =
+ comment.createdAt instanceof Date
+ ? comment.createdAt.toISOString()
+ : comment.createdAt
+ ? String(comment.createdAt)
+ : undefined;
items.push({
id: comment.id || comment.clientId || `${comment.createdAt}`,
kind: "message",
@@ -90,6 +99,12 @@ export function commentsToTaskChatItems(
agentIcon,
onBehalfOfUserName,
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
+ // System notices carry their structured hints through to the render
+ // layer (PAP-443); other authors keep the item lean.
+ presentation: kind === "system" ? comment.presentation ?? null : undefined,
+ metadata: kind === "system" ? comment.metadata ?? null : undefined,
+ runAgentId: kind === "system" ? comment.runAgentId ?? null : undefined,
+ createdAtIso: kind === "system" ? createdAtIso : undefined,
});
}
return items;
diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts
index bbf42b6459c..e465c8044cd 100644
--- a/ui/src/components/task-chat/task-chat-model.ts
+++ b/ui/src/components/task-chat/task-chat-model.ts
@@ -12,6 +12,7 @@
* inventory for the mapping. No timing/motion values live here — those are
* CSS motion tokens in ui/src/index.css.
*/
+import type { IssueCommentMetadata, IssueCommentPresentation } from "@paperclipai/shared";
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
/** Who authored a thread row — the primary legibility signal. */
@@ -100,6 +101,17 @@ export interface TaskChatMessageItem {
* Expanding still nests the tool history beneath the bubble.
*/
attachedTurn?: TaskChatTurnItem;
+ /**
+ * Structured system-notice fields (PAP-443), carried only for
+ * author === "system": the comment's server-authored presentation hints and
+ * metadata sections drive the collapsed one-line row + expandable detail.
+ */
+ presentation?: IssueCommentPresentation | null;
+ metadata?: IssueCommentMetadata | null;
+ /** Agent that owns the source run, used to build run-detail links in metadata rows. */
+ runAgentId?: string | null;
+ /** Raw comment timestamp (ISO) — the collapsed system row shows relative time. */
+ createdAtIso?: string;
}
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
diff --git a/ui/src/index.css b/ui/src/index.css
index 3b4b143346d..b68251e0c70 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -581,6 +581,7 @@
.tc-enter-tool { animation: tc-fade-rise var(--motion-tool-enter) var(--motion-ease-out-expo) both; }
.tc-enter-status { animation: tc-status-in var(--motion-status-enter) var(--motion-ease-out-expo) both; }
.tc-enter-cot-line { animation: tc-fade-rise var(--motion-duration-fast) var(--motion-ease-standard) both; }
+.tc-notice-chevron { transition-duration: var(--motion-duration-fast); }
.tc-reveal-diff { animation: tc-fade-in var(--motion-diff-reveal) var(--motion-ease-standard) both; }
.tc-approval { animation: tc-approval-pulse var(--motion-approval-pulse) var(--motion-ease-standard) infinite; }
.tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; }
diff --git a/ui/src/lib/system-notice-comment.ts b/ui/src/lib/system-notice-comment.ts
index c06e21e78fc..6d3bd463362 100644
--- a/ui/src/lib/system-notice-comment.ts
+++ b/ui/src/lib/system-notice-comment.ts
@@ -57,7 +57,7 @@ function mapMetadataRow(
};
}
case "run_link": {
- const runAgentId = ctx.runAgentId ?? null;
+ const runAgentId = row.agentId ?? ctx.runAgentId ?? null;
const href = runAgentId ? `/agents/${runAgentId}/runs/${row.runId}` : undefined;
return {
kind: "run",
diff --git a/ui/src/lib/system-notice-humanizer.test.ts b/ui/src/lib/system-notice-humanizer.test.ts
new file mode 100644
index 00000000000..88d8787dcef
--- /dev/null
+++ b/ui/src/lib/system-notice-humanizer.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it } from "vitest";
+import { humanizeSystemNotice } from "./system-notice-humanizer";
+
+describe("humanizeSystemNotice", () => {
+ it("uses presentation title and tone when present", () => {
+ const result = humanizeSystemNotice({
+ body: "Paperclip automatically retried dispatch but it still has no live execution path.",
+ presentation: {
+ kind: "system_notice",
+ tone: "danger",
+ title: "Run recovery exhausted",
+ detailsDefaultOpen: false,
+ },
+ });
+ expect(result).toEqual({ title: "Run recovery exhausted", tone: "danger" });
+ });
+
+ it("classifies claude_auth_required retry failures", () => {
+ const result = humanizeSystemNotice({
+ body:
+ "Paperclip automatically retried continuation for this assigned `in_progress` issue during terminal run " +
+ "recovery, but it still has no live execution path. Latest retry failure: `claude_auth_required` - adapter " +
+ "handshake failed. Moving it to `blocked` so it is visible for intervention.",
+ });
+ expect(result.title).toBe("Task paused — Claude needs re-authentication");
+ expect(result.tone).toBe("warning");
+ });
+
+ it("classifies configuration_incomplete failures", () => {
+ const result = humanizeSystemNotice({
+ body:
+ "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
+ "Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action.",
+ });
+ expect(result.title).toBe("Task paused — a secret/config binding is missing");
+ expect(result.tone).toBe("warning");
+ });
+
+ it("classifies missing secret bindings even without a failure code", () => {
+ const result = humanizeSystemNotice({
+ body:
+ "Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
+ "Moving it to `blocked` so an operator can bind the missing secret(s).",
+ });
+ expect(result.title).toBe("Task paused — a secret/config binding is missing");
+ });
+
+ it("classifies workspace validation failures", () => {
+ const result = humanizeSystemNotice({
+ body:
+ "Paperclip stopped before launching the local adapter because the issue workspace failed validation. " +
+ "This prevents git-sensitive adapters from running in an unrelated fallback cwd.",
+ });
+ expect(result.title).toBe("Task paused — workspace problem");
+ expect(result.tone).toBe("warning");
+ });
+
+ it("names the recovery owner from the markdown link for generic no-live-execution-path notices", () => {
+ const result = humanizeSystemNotice({
+ body:
+ "Paperclip automatically retried dispatch for this assigned `todo` issue during terminal run recovery, " +
+ "but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.\n\n" +
+ "- Recovery action: `ra-123`\n" +
+ "- Recovery owner: [Dana the Manager](/PAP/agents/agent-9)\n" +
+ "- Next action: the recovery owner should restore a live execution path.",
+ });
+ expect(result.title).toBe("Task paused — waiting on Dana the Manager");
+ expect(result.tone).toBe("warning");
+ });
+
+ it("still humanizes no-live-execution-path notices without an owner link", () => {
+ const result = humanizeSystemNotice({
+ body:
+ "Paperclip automatically retried dispatch, but it still has no live execution path. " +
+ "Moving it to `blocked` so it is visible for intervention.",
+ });
+ expect(result.title).toBe("Task paused — waiting on a recovery owner");
+ });
+
+ it("falls back to System update plus a truncated first sentence", () => {
+ const longFirstSentence =
+ "The heartbeat scheduler observed an unusual condition while reconciling this issue's run ledger against the " +
+ "control plane state and recorded the discrepancy for later review.";
+ const result = humanizeSystemNotice({ body: `${longFirstSentence} Second sentence here.` });
+ expect(result.title).toBe("System update");
+ expect(result.tone).toBe("neutral");
+ expect(result.detail).toBeDefined();
+ expect(result.detail!.length).toBeLessThanOrEqual(80);
+ expect(result.detail!.endsWith("…")).toBe(true);
+ });
+
+ it("keeps a short first sentence untruncated in the fallback detail", () => {
+ const result = humanizeSystemNotice({ body: "Run rescheduled. More context follows." });
+ expect(result).toEqual({
+ title: "System update",
+ tone: "neutral",
+ detail: "Run rescheduled.",
+ });
+ });
+});
diff --git a/ui/src/lib/system-notice-humanizer.ts b/ui/src/lib/system-notice-humanizer.ts
new file mode 100644
index 00000000000..6b2177eada8
--- /dev/null
+++ b/ui/src/lib/system-notice-humanizer.ts
@@ -0,0 +1,92 @@
+import type { IssueCommentPresentation } from "@paperclipai/shared";
+import type { SystemNoticeTone } from "../components/SystemNotice";
+
+/**
+ * Pure classifier for system comments in the chat shell (PAP-443 / PAP-442
+ * Phase 1): derive a short plain-English one-liner + tone for the collapsed
+ * row. Server-authored presentation hints win when present; otherwise the
+ * known recovery families are pattern-matched from the comment body. Nothing
+ * here suppresses data — the full body stays available behind the expand.
+ */
+export interface HumanizedSystemNotice {
+ title: string;
+ tone: SystemNoticeTone;
+ /** Optional muted trailing snippet for the generic fallback row. */
+ detail?: string;
+}
+
+const FALLBACK_TITLE = "System update";
+const DETAIL_MAX_CHARS = 80;
+
+/** Failure code the recovery comments embed as "Latest retry failure: `code`". */
+function retryFailureCode(body: string): string | null {
+ const match = body.match(/Latest retry failure: `([^`]+)`/);
+ return match ? match[1] : null;
+}
+
+/** Owner display name from a "Recovery owner: [Name](/…/agents/…)" markdown link. */
+function recoveryOwnerName(body: string): string | null {
+ const match = body.match(/Recovery owner: \[([^\]]+)\]\(/);
+ const name = match?.[1]?.trim();
+ return name && name.length > 0 ? name : null;
+}
+
+function firstSentence(body: string): string | undefined {
+ const firstLine = body
+ .split("\n")
+ .map((line) => line.trim())
+ .find((line) => line.length > 0);
+ if (!firstLine) return undefined;
+ const sentenceEnd = firstLine.search(/[.!?](\s|$)/);
+ const sentence = sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine;
+ if (sentence.length <= DETAIL_MAX_CHARS) return sentence;
+ return `${sentence.slice(0, DETAIL_MAX_CHARS - 1).trimEnd()}…`;
+}
+
+export function humanizeSystemNotice(input: {
+ body: string;
+ presentation?: IssueCommentPresentation | null;
+}): HumanizedSystemNotice {
+ const presentationTitle = input.presentation?.title?.trim();
+ const presentationTone = input.presentation?.tone;
+ if (presentationTitle) {
+ return { title: presentationTitle, tone: presentationTone ?? "neutral" };
+ }
+
+ const body = input.body ?? "";
+ const code = retryFailureCode(body);
+
+ if (code === "claude_auth_required") {
+ return {
+ title: "Task paused — Claude needs re-authentication",
+ tone: presentationTone ?? "warning",
+ };
+ }
+ if (code === "configuration_incomplete" || body.includes("secret/env bindings are missing")) {
+ return {
+ title: "Task paused — a secret/config binding is missing",
+ tone: presentationTone ?? "warning",
+ };
+ }
+ if (code === "workspace_validation_failed" || body.includes("workspace failed validation")) {
+ return {
+ title: "Task paused — workspace problem",
+ tone: presentationTone ?? "warning",
+ };
+ }
+ if (body.includes("no live execution path")) {
+ const owner = recoveryOwnerName(body);
+ return {
+ title: owner
+ ? `Task paused — waiting on ${owner}`
+ : "Task paused — waiting on a recovery owner",
+ tone: presentationTone ?? "warning",
+ };
+ }
+
+ return {
+ title: FALLBACK_TITLE,
+ tone: presentationTone ?? "neutral",
+ detail: firstSentence(body),
+ };
+}
From 19be4cf9278b70bc151063778a94bf38bfd5c903 Mon Sep 17 00:00:00 2001
From: Evyatar Bluzer
Date: Sat, 8 Aug 2026 08:46:47 +0700
Subject: [PATCH 016/201] refactor(a11y): add scope=col to the agent costs
table headers (#1789)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - The agent detail page has a Costs section. That section renders a
data table of per-run spend.
> - The table has a header row, but its `` elements carry no `scope`
attribute.
> - A screen reader uses `scope="col"` to bind each data cell to its
column header. Without it, the reader announces a number without telling
the user which column it belongs to.
> - A table of costs is exactly the case where that hurts. Every cell is
a bare figure.
> - This pull request adds `scope="col"` to the five header cells in
that table.
> - The benefit is that assistive technology announces the cost table
correctly. The change is markup only, so sighted users see no
difference.
## Linked Issues or Issue Description
No public GitHub issue covers this. The problem is described in-PR,
following the enhancement template.
**What existing behavior does this improve?**
The Costs table rendered by `CostsSection` in
`ui/src/pages/AgentDetail.tsx`.
**Subsystem affected**
ui/ — React + Vite board UI
**Current behavior**
The table renders five header cells: Date, Run, Input, Output, and Cost.
None of them set `scope`. A screen reader must guess the header-to-cell
relationship, so a user hears a value with no column name attached to
it.
**Proposed behavior**
Each header cell sets `scope="col"`. A screen reader then announces the
column name together with each cell, so a cost figure is read as part of
the Cost column.
**Reason and benefit**
`scope` is the standard way to associate header cells with data cells in
an HTML table. The attribute has no visual effect, so the fix carries no
design cost and makes the table usable with a screen reader.
**Breaking changes**
None. `scope` is a presentational-neutral HTML attribute. No component
API, no styling, and no test changes.
**Related pull requests**
- #2215 proposed the same attribute for the Routines table. It is
closed, because that table no longer exists on master.
- #1524 and #1522 applied `scope="col"` to other tables. Both are
closed.
## What Changed
- Added `scope="col"` to the five ` ` elements in the `CostsSection`
table in `ui/src/pages/AgentDetail.tsx`.
- Rebased the branch onto current master.
- Dropped the original `ui/src/pages/Routines.tsx` hunks. Master rebuilt
the Routines page around folder-grouped rows, so the table those hunks
targeted no longer exists.
- Dropped the original `HintIcon` opacity change. It altered a visible
colour, which is out of scope for a markup-only accessibility fix.
## Verification
- Run `pnpm --filter @paperclipai/ui typecheck` and `pnpm --filter
@paperclipai/ui build`. This is a markup-only change, so a clean
type-check and build is the relevant automated signal.
- Open an agent detail page and go to the Costs section. Inspect the
header row. Each ` ` now carries `scope="col"`.
- Navigate the same table with a screen reader, cell by cell. Each cell
is announced with its column name.
- Compare the rendered page before and after. It is unchanged, because
`scope` has no styling effect.
## Risks
Low risk. The change adds one standard HTML attribute to five header
cells in a single table. It introduces no code path, changes no
component API, and has no visual effect. The worst case is that the
attribute is redundant for a reader that already infers the column,
which is harmless.
## Model Used
Anthropic Claude Opus 5, exact model ID `claude-opus-5`. It ran with
extended thinking and repository read/write tools, inside a
maintainer-operated triage agent. The model rebased the branch, dropped
the two out-of-scope hunks, and wrote this description. The original
change was authored by @bluzername, and the model used for that work is
not recorded here.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` references)
- [x] I have considered and documented any risks above
- [x] My branch name describes the change and contains no internal
ticket id
- [ ] I have run tests locally and they pass — not run. This is a
markup-only change and the package has no test covering this table.
- [ ] I have added or updated tests where applicable — no test added,
which is why this PR is titled `refactor:`.
- [ ] I have updated relevant documentation to reflect my changes — no
documentation describes this markup.
- [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work — not checked by the maintainer who rebased this.
- [ ] All Paperclip CI gates are green — CI re-runs on this push.
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
Greptile re-reviews on this push.
---------
Co-authored-by: Andrew Aymeloglu
---
ui/src/pages/AgentDetail.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx
index 4e21a7d2809..b76eb60975d 100644
--- a/ui/src/pages/AgentDetail.tsx
+++ b/ui/src/pages/AgentDetail.tsx
@@ -1743,11 +1743,11 @@ function CostsSection({
- Date
- Run
- Input
- Output
- Cost
+ Date
+ Run
+ Input
+ Output
+ Cost
From ebf2b8ff7904bf4aca65ee21e31cd639ce2eff79 Mon Sep 17 00:00:00 2001
From: Manav Shrivastava
Date: Mon, 10 Aug 2026 03:59:44 +0530
Subject: [PATCH 017/201] fix(server): persist worktree runtime port when
ambient PORT does not match (#1849) (#1930)
## What was done
Replaced the strict `!nonEmpty(process.env.PORT)` guard in
`maybePersistWorktreeRuntimePorts` with a new `isPortPinnedByRuntimeEnv`
helper function. This function checks if `process.env.PORT` is set, but
only suppresses persisting the port to configuration if the ambient
`PORT` matches the newly allocated `selectedPort`.
## Why it matters
Fixes issue #1849. Previously, if an ambient `PORT` environment variable
was exported globally (like inheriting from the shell running the parent
workspace), worktrees would silently fail to write their
collision-avoiding ports (e.g. 3103 instead of 3100) back to their
respective local `config.json` files. This resulted in orphaned
sub-worktrees and lost port tracking on reboot. With this fix, worktrees
correctly persist their assigned ports even while nested under an
inherited environment variables stack, while continuing to respect
manual, explicit pinning.
## How to verify
1. Export a port in the shell explicitly: `export PORT=3100`.
2. Launch a sub-worktree instance which receives an auto-assigned free
port (e.g., `3103`).
3. View the underlying `config.json` for that worktree inside
`.paperclip/worktrees/`.
4. The config file should correctly contain `{"server": {"port": 3103}}`
rather than dropping the write operation.
## Risks
None expected. The `Number()` and `Number.isInteger()` checks handle
parsing edge cases cleanly, defaulting robustly to preventing writes if
`process.env.PORT` is somehow malformed (e.g., set to a non-integer),
ensuring absolute safety during misconfigurations.
Co-authored-by: manavshrivastavagit
---
server/src/worktree-config.ts | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts
index 913451d0be6..b05686204e6 100644
--- a/server/src/worktree-config.ts
+++ b/server/src/worktree-config.ts
@@ -650,6 +650,14 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): {
return { repairedConfig, repairedEnv };
}
+function isPortPinnedByRuntimeEnv(rawValue: string | null | undefined, selectedPort: number): boolean {
+ const normalized = nonEmpty(rawValue);
+ if (!normalized) return false;
+ const parsedPort = Number(normalized);
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0) return true;
+ return parsedPort === selectedPort;
+}
+
export function maybePersistWorktreeRuntimePorts(input: {
serverPort: number;
databasePort?: number | null;
@@ -667,7 +675,7 @@ export function maybePersistWorktreeRuntimePorts(input: {
const { config, changed } = applyRuntimePortSelectionToConfig(fileConfig, {
serverPort: input.serverPort,
databasePort: input.databasePort,
- allowServerPortWrite: !nonEmpty(process.env.PORT),
+ allowServerPortWrite: !isPortPinnedByRuntimeEnv(process.env.PORT, input.serverPort),
allowDatabasePortWrite: !nonEmpty(process.env.DATABASE_URL),
});
@@ -675,3 +683,4 @@ export function maybePersistWorktreeRuntimePorts(input: {
writeConfigFile(context.configPath, config);
}
}
+
From 6a4e2e1b8c7129f6f913ae458ab0be9cba50bd6a Mon Sep 17 00:00:00 2001
From: LeonSGP <154585401+LeonSGP43@users.noreply.github.com>
Date: Mon, 10 Aug 2026 09:20:09 +0800
Subject: [PATCH 018/201] fix(routes): return 409 for routine checkout
conflicts (#3790)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip orchestrates AI agents and relies on issue checkout as the
core task-claiming primitive
> - The issue checkout route is the HTTP boundary that translates
service and database outcomes into agent-usable API responses
> - Routine-linked issues are protected by the partial unique index
`issues_open_routine_execution_uq`, which covers only rows whose
`execution_run_id` is set
> - `svc.checkout` sets `execution_run_id`, so a concurrent claim moves
the row into that index and can raise a 23505 mid-request
> - Unhandled, that surfaces as a 500 and crashes the agent run instead
of being a recoverable conflict
> - Drizzle wraps driver failures in its own `Failed query: ...` error,
so the Postgres error carrying `code` and the constraint name is
reachable only through `cause`
> - This pull request translates that violation into a 409 at the
checkout route, detecting it through the cause chain the way
`isReviewPathRecoveryIdempotencyConflict` already does
> - The benefit is that agents handle routine execution contention
through the normal heartbeat conflict path instead of failing on an
internal server error
## Linked Issues or Issue Description
Fixes #3660
Related pull requests found while searching for duplicates:
- #3699 — an earlier attempt at this same route-level fix, closed
unmerged. Same shape, and its check has the flat-error bug described
under Verification.
- #3633 — related work on postgres.js `constraint_name` handling in
conflict detection.
- #5662 — covers the adoption path (`assertCheckoutOwner`) that this
pull request does not.
## What Changed
- Added `server/src/db-errors.ts` with `isUniqueViolation(error,
constraintName?)`, which walks the `cause` chain (depth-capped) and
accepts the postgres.js `constraint_name`, the node-postgres
`constraint`, or the driver message as evidence of SQLSTATE 23505.
- Wrapped `svc.checkout()` in `POST /issues/:id/checkout` with a narrow
try/catch that uses that helper to return **409 Conflict** for
`issues_open_routine_execution_uq`, and rethrows every other error
unchanged.
- Added `server/src/__tests__/db-errors.test.ts` covering the wrapped
and bare error shapes, both constraint field names, the message
fallback, non-matching constraints, non-unique-violation codes, and a
self-referential cause chain.
## Verification
- The new unit test includes the wrapped case `{ cause: { code: "23505",
constraint_name: ... } }` that a flat `error.code` check fails, so it is
a real regression guard rather than a restatement of the implementation.
- The wrapped shape is what this codebase observes in practice:
`server/src/__tests__/plugin-tenant-isolation.test.ts` asserts
`cause?.code === "23505"` against embedded Postgres,
`packages/db/src/pipelines-schema.test.ts` asserts that constraint
failures throw `Failed query`, and
`server/src/services/recovery/review-path-recovery.ts` walks the same
chain.
- CI (verify, e2e, policy) exercises this change against current master
through the pull request merge ref.
- Not verified locally: no monorepo install or typecheck was run in this
environment.
## Risks
- Low. One route gains a catch that matches a single constraint and
rethrows all other errors, so no unrelated failure can be swallowed.
- The 409 body `{ error: ... }` matches the other 409 responses this
route already returns.
- Scope limit: this covers the checkout route only. The adoption path
reached through `assertCheckoutOwner` (heartbeat, plugins, and pipelines
routes) can still surface the same violation as a 500; #5662 targets
that path.
- `isUniqueViolation` is new and intentionally generic. Existing flat
23505 checks elsewhere in the server are left untouched by this pull
request.
## Model Used
- Original change: OpenAI Codex, GPT-5-class tool-using coding agent in
the Codex CLI environment; exact backend model revision is not exposed
in that runtime.
- Follow-up revision (cause-chain detection plus tests): Anthropic
Claude Opus 5 (`claude-opus-5`), tool-using coding agent with extended
thinking and code execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [ ] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Andrew Aymeloglu
---
server/src/__tests__/db-errors.test.ts | 51 ++++++++++++++++++++++++++
server/src/db-errors.ts | 33 +++++++++++++++++
server/src/routes/issues.ts | 14 ++++++-
3 files changed, 97 insertions(+), 1 deletion(-)
create mode 100644 server/src/__tests__/db-errors.test.ts
create mode 100644 server/src/db-errors.ts
diff --git a/server/src/__tests__/db-errors.test.ts b/server/src/__tests__/db-errors.test.ts
new file mode 100644
index 00000000000..80dbb536cf6
--- /dev/null
+++ b/server/src/__tests__/db-errors.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { isUniqueViolation } from "../db-errors.js";
+
+const CONSTRAINT = "issues_open_routine_execution_uq";
+
+describe("isUniqueViolation", () => {
+ it("matches a bare postgres.js unique violation", () => {
+ expect(isUniqueViolation({ code: "23505", constraint_name: CONSTRAINT }, CONSTRAINT)).toBe(true);
+ });
+
+ it("matches the node-postgres constraint field", () => {
+ expect(isUniqueViolation({ code: "23505", constraint: CONSTRAINT }, CONSTRAINT)).toBe(true);
+ });
+
+ it("matches the error Drizzle wraps around the driver failure", () => {
+ const wrapped = new Error("Failed query: update \"issues\" set \"execution_run_id\" = $1");
+ (wrapped as { cause?: unknown }).cause = { code: "23505", constraint_name: CONSTRAINT };
+ expect(isUniqueViolation(wrapped, CONSTRAINT)).toBe(true);
+ });
+
+ it("falls back to the driver message when the constraint name is not surfaced", () => {
+ expect(isUniqueViolation({
+ cause: {
+ code: "23505",
+ message: `duplicate key value violates unique constraint "${CONSTRAINT}"`,
+ },
+ }, CONSTRAINT)).toBe(true);
+ });
+
+ it("matches any unique violation when no constraint is named", () => {
+ expect(isUniqueViolation({ cause: { code: "23505" } })).toBe(true);
+ });
+
+ it("ignores a unique violation on a different constraint", () => {
+ expect(isUniqueViolation({ cause: { code: "23505", constraint_name: "issues_identifier_idx" } }, CONSTRAINT))
+ .toBe(false);
+ });
+
+ it("ignores errors that are not unique violations", () => {
+ expect(isUniqueViolation({ cause: { code: "23503", constraint_name: CONSTRAINT } }, CONSTRAINT)).toBe(false);
+ expect(isUniqueViolation(new Error("boom"), CONSTRAINT)).toBe(false);
+ expect(isUniqueViolation(null, CONSTRAINT)).toBe(false);
+ expect(isUniqueViolation(undefined, CONSTRAINT)).toBe(false);
+ });
+
+ it("stops walking a self-referential cause chain", () => {
+ const looped: { cause?: unknown } = {};
+ looped.cause = looped;
+ expect(isUniqueViolation(looped, CONSTRAINT)).toBe(false);
+ });
+});
diff --git a/server/src/db-errors.ts b/server/src/db-errors.ts
new file mode 100644
index 00000000000..667fa655800
--- /dev/null
+++ b/server/src/db-errors.ts
@@ -0,0 +1,33 @@
+const UNIQUE_VIOLATION = "23505";
+const MAX_CAUSE_DEPTH = 4;
+
+/**
+ * Recognizes a Postgres unique-constraint violation (SQLSTATE 23505).
+ *
+ * Drizzle wraps driver failures in its own `Failed query: ...` error, so the
+ * Postgres error that carries the code and the constraint name is reachable
+ * only through `cause` — inspecting the thrown error directly misses it. The
+ * constraint name itself lands on `constraint_name` under postgres.js and on
+ * `constraint` under node-postgres, and is not always surfaced at all, so fall
+ * back to the driver message.
+ */
+export function isUniqueViolation(error: unknown, constraintName?: string): boolean {
+ let current: unknown = error;
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && current && typeof current === "object"; depth += 1) {
+ const candidate = current as {
+ code?: unknown;
+ constraint?: unknown;
+ constraint_name?: unknown;
+ message?: unknown;
+ cause?: unknown;
+ };
+ if (candidate.code === UNIQUE_VIOLATION) {
+ if (!constraintName) return true;
+ const constraint = candidate.constraint ?? candidate.constraint_name;
+ if (constraint === constraintName) return true;
+ if (typeof candidate.message === "string" && candidate.message.includes(constraintName)) return true;
+ }
+ current = candidate.cause;
+ }
+ return false;
+}
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index beb6459dd62..02084fe7cfb 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -103,6 +103,7 @@ import {
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
import { getTelemetryClient } from "../telemetry.js";
+import { isUniqueViolation } from "../db-errors.js";
import type { StorageService } from "../storage/types.js";
import { validate } from "../middleware/validate.js";
import * as serviceIndex from "../services/index.js";
@@ -9983,7 +9984,18 @@ export function issueRoutes(
const checkoutRunId = requireAgentRunId(req, res);
if (req.actor.type === "agent" && !checkoutRunId) return;
- const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
+ let updated;
+ try {
+ updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
+ } catch (error) {
+ if (isUniqueViolation(error, "issues_open_routine_execution_uq")) {
+ res.status(409).json({
+ error: "Another execution for this routine is already in progress",
+ });
+ return;
+ }
+ throw error;
+ }
const actor = getActorInfo(req);
if (updated?.harnessKind === "skill_test") {
await companySkillsSvc.markTestRunRunning(updated.companyId, updated.id);
From f9173782cd12bc5e47466150892c0724789d26af Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 12:16:55 -0700
Subject: [PATCH 019/201] feat(release): add smoke-gated nightly channel and
lane-separated Docker tags (#11006)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem publishes the `paperclipai` npm package set
and the Docker images on two lanes: canary on every master push, and
stable on manual promotion
> - There is no middle ground between those lanes. Users must track
every merge or wait weeks for a stable. Docker `:latest` also tracks
master, so Docker users have no stable image at all
> - A calm prerelease lane needs to exist, and it must never ship a
build that failed its checks
> - This pull request adds the nightly channel: a scheduled job that
selects the newest master commit with a green canary publish, runs the
full release smoke suite against that exact published canary, and only
then republishes it as the nightly. It also separates Docker tags by
lane, so `:latest` finally means stable
> - The benefit is that users can follow prereleases at a nightly
cadence with a smoke-tested guarantee, and Docker users get real
`:canary`, `:nightly`, and stable image tags
## Linked Issues or Issue Description
**Subsystem affected**
Release automation: `scripts/release.sh`, `scripts/release-lib.sh`,
`.github/workflows/release.yml`, `.github/workflows/docker.yml`,
`.github/workflows/release-smoke.yml`.
**Problem or motivation**
The project publishes only `canary` (every master push) and `latest`
(manual stable). Users who want prereleases without per-merge churn have
no option. Docker has a second problem: master builds overwrite
`:latest`, and CI-published stables never produced Docker images,
because tags pushed with `GITHUB_TOKEN` do not fire the `v*` tag trigger
in `docker.yml`. No stable-versioned image exists in ghcr today.
**Proposed solution**
Add a `nightly` channel. A scheduled job selects the newest
canary-tagged master commit, smoke-tests that exact published canary,
and republishes the same commit as `YYYY.MDD.P-nightly.N` under the
`nightly` dist-tag. Separate Docker tags by lane (`:canary` for master,
`:nightly` for nightly tags, `:latest` plus version tags for stable tags
only), and have the release jobs dispatch `docker.yml` at the new tag so
lane images actually build.
**Alternatives considered**
Moving the `nightly` dist-tag to the existing canary version without a
republish. Rejected: the version string would say `canary` while the
user is on nightly, which breaks at-a-glance lane identification in bug
reports and `--version` output.
## What Changed
- `scripts/release-lib.sh`: channel-parameterized
`next_prerelease_version` and `prerelease_tag_name` helpers (canary
helpers delegate to them), a `require_channel_tag_at_head` guard, and
the no-provenance retry for Sigstore transparency-log duplicates now
covers the `nightly` dist-tag as well as `canary`
- `scripts/release.sh`: new `nightly` channel. It requires HEAD to carry
a `canary/v*` tag, publishes the full public package set as
`YYYY.MDD.P-nightly.N` under dist-tag `nightly`, and tags the source
commit `nightly/vYYYY.MDD.P-nightly.N`
- `.github/workflows/release.yml`: scheduled nightly chain (09:00 UTC) —
select candidate, smoke it via `release-smoke.yml`, publish on green
under the existing `npm-canary` environment, push the tag, dispatch
`docker.yml`. New `channel` dispatch input (default `stable`, so
existing stable dispatches are unchanged) with `nightly_source_version`
and `dry_run` support for forced runs. The stable path now also
dispatches `docker.yml` at the new `v*` tag
- `.github/workflows/docker.yml`: lane tag mapping for both image jobs —
master pushes publish `:canary` and no longer move `:latest`;
`nightly/v*` tags publish `:nightly`; only stable `v*` tags publish
`:latest` and the versioned tags. New `workflow_dispatch` trigger for
the release-job dispatches. Build-version stamping uses the exact
nightly version on nightly tag builds
- `.github/workflows/release-smoke.yml`: `nightly` added to the dispatch
choice list
- `doc/CHANNELS.md` (new): user-facing guide to the channels
- `doc/RELEASING.md`: nightly lane documentation, Docker tag mapping
table, and a nightly failure playbook
- `doc/RELEASE-AUTOMATION-SETUP.md`: note that nightly reuses
`npm-canary` and needs no npm trusted-publisher changes
- Tests: channel-parameterized version helper coverage in
`scripts/release-registry-versions.test.mjs`, and nightly flow coverage
(publish identity, notes not required, canary-tag guard) in
`scripts/__tests__/release-dry-run-notes.test.mjs`
## Verification
- `node --test` on the release script suites: 68 pass, including 6 new
tests. The only failure, `acpx-patch-packaging.test.mjs`, needs
installed `node_modules` and fails identically on a pristine checkout of
master in the same environment
- `bash -n` on both shell scripts and YAML parse of all three workflows
- Live fail-path check: `./scripts/release.sh nightly --print-version`
from a master tip with no canary tag fails with `HEAD has no canary/v*
tag`
- Live success-path check: the same command from the
`canary/v2026.806.0-canary.7` commit prints `2026.806.0-nightly.0`
- Live selection check: the candidate-selection shell logic run against
the real repository selects the commit of `canary/v2026.806.0-canary.7`,
which matches the current npm `canary` dist-tag exactly
- After merge: dispatch `release.yml` with `channel: nightly` and
`dry_run: true` to preview, then a real forced run to validate end to
end before the first scheduled run
## Risks
- Docker `:latest` changes meaning from "latest master build" to "latest
stable release". This is deliberate and will be announced. Users who
want the old behavior pull `:canary`. Until the first stable release
after this change, `:latest` stays at its current (master-built) image
- The nightly is a rebuild of the same source commit, not the
byte-identical canary artifact that was smoked. The lockfile pins
dependencies, and the publish path's registry-visibility and
clean-prefix install gates still run on the nightly artifacts
- All npm publishing must stay inside `release.yml` because npm trusted
publishing pins that workflow file per package. The nightly jobs were
added to `release.yml` for exactly that reason; this constraint is now
documented in `RELEASING.md`
- The stable-lane Docker dispatch fails gracefully (a warning with
manual instructions) when the source ref predates `docker.yml`'s
`workflow_dispatch` trigger
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use (repository exploration, local test
execution, live registry and git verification). All code, tests, and
docs in this PR were model-authored under human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.github/workflows/docker.yml | 52 +++-
.github/workflows/release-smoke.yml | 1 +
.github/workflows/release.yml | 249 +++++++++++++++++-
doc/CHANNELS.md | 79 ++++++
doc/RELEASE-AUTOMATION-SETUP.md | 6 +
doc/RELEASING.md | 109 +++++++-
.../__tests__/release-dry-run-notes.test.mjs | 53 +++-
scripts/release-lib.sh | 80 +++++-
scripts/release-registry-versions.test.mjs | 40 +++
scripts/release.sh | 54 ++--
scripts/verify-release-registry-state.mjs | 21 +-
.../verify-release-registry-state.test.mjs | 41 ++-
12 files changed, 719 insertions(+), 66 deletions(-)
create mode 100644 doc/CHANNELS.md
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index ba9c25e4009..42dbbc602bb 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -6,6 +6,12 @@ on:
- "master"
tags:
- "v*"
+ - "nightly/v*"
+ # Release workflows push lane tags with GITHUB_TOKEN, and GitHub suppresses
+ # push-triggered runs for those, so release.yml dispatches this workflow at
+ # the new tag ref instead. The tag mapping below keys off github.ref either
+ # way.
+ workflow_dispatch:
permissions:
contents: read
@@ -40,7 +46,16 @@ jobs:
id: build-version
run: |
set -euo pipefail
- version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
+ case "${GITHUB_REF}" in
+ refs/tags/nightly/v*)
+ # Lane tags carry the exact published version; stamp it verbatim
+ # instead of describing drift from the nearest stable tag.
+ version="${GITHUB_REF#refs/tags/nightly/v}"
+ ;;
+ *)
+ version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
+ ;;
+ esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-}"
@@ -131,15 +146,20 @@ jobs:
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
+ # Lane tag mapping: master pushes publish `:canary`, nightly/v* tags
+ # publish `:nightly`, and only stable v* tags move `:latest` and the
+ # versioned tags. `:sha-` is published on every build.
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
- type=raw,value=latest,enable={{is_default_branch}}
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
+ type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
@@ -197,7 +217,16 @@ jobs:
id: build-version
run: |
set -euo pipefail
- version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
+ case "${GITHUB_REF}" in
+ refs/tags/nightly/v*)
+ # Lane tags carry the exact published version; stamp it verbatim
+ # instead of describing drift from the nearest stable tag.
+ version="${GITHUB_REF#refs/tags/nightly/v}"
+ ;;
+ *)
+ version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
+ ;;
+ esac
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "Stamping build version: ${version:-}"
@@ -288,8 +317,9 @@ jobs:
echo "last=${last}" >> "$GITHUB_OUTPUT"
echo "count=${count}" >> "$GITHUB_OUTPUT"
- # Published under the same tag set with a `-cloud` suffix
- # (sha--cloud, latest-cloud, -cloud).
+ # Published under the same lane tag set as the self-hosted image, with a
+ # `-cloud` suffix (canary-cloud, nightly-cloud, latest-cloud,
+ # -cloud, sha--cloud).
- name: Docker meta (cloud)
id: meta-cloud
uses: docker/metadata-action@v6
@@ -298,9 +328,11 @@ jobs:
flavor: |
suffix=-cloud,onlatest=true
tags: |
- type=raw,value=latest,enable={{is_default_branch}}
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
+ type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha
labels: |
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml
index 923bb896a3e..6429d9ef1cd 100644
--- a/.github/workflows/release-smoke.yml
+++ b/.github/workflows/release-smoke.yml
@@ -10,6 +10,7 @@ on:
type: choice
options:
- canary
+ - nightly
- latest
host_port:
description: Host port for the Docker smoke container
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d46cf1fb594..024244408bf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,10 +4,21 @@ on:
push:
branches:
- master
+ schedule:
+ # Nightly cut at 09:00 UTC, after the workday's merges have settled.
+ - cron: "0 9 * * *"
workflow_dispatch:
inputs:
+ channel:
+ description: Release channel to publish
+ required: true
+ type: choice
+ options:
+ - stable
+ - nightly
+ default: stable
source_ref:
- description: Commit SHA, branch, or tag to publish as stable
+ description: (stable) Commit SHA, branch, or tag to publish as stable
required: true
type: string
default: master
@@ -15,8 +26,12 @@ on:
description: Enter a UTC date in YYYY-MM-DD format, for example 2026-03-18. Do not enter a version string. The workflow will resolve that date to a stable version such as 2026.318.0, then 2026.318.1 for the next same-day stable.
required: false
type: string
+ source_version:
+ description: (nightly) Explicit canary version to promote, for example 2026.806.0-canary.7. Leave empty to select the newest canary on master.
+ required: false
+ type: string
dry_run:
- description: Preview the stable release without publishing
+ description: Preview the release without publishing
required: true
type: boolean
default: false
@@ -97,14 +112,218 @@ jobs:
fi
git push origin "refs/tags/${tag}"
+ # ----- Nightly lane -----------------------------------------------------
+ # Once a night (or on a forced nightly dispatch), promote the newest master
+ # commit that already shipped a green canary: smoke-test that exact
+ # published canary first, then republish the same commit under the nightly
+ # identity. The candidate commit already passed release-verify during its
+ # canary publish, so the nightly publish skips re-verification.
+
+ select_nightly:
+ if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly')
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ outputs:
+ proceed: ${{ steps.select.outputs.proceed }}
+ sha: ${{ steps.select.outputs.sha }}
+ canary_version: ${{ steps.select.outputs.canary_version }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ ref: master
+ fetch-depth: 0
+
+ - name: Select nightly candidate
+ id: select
+ env:
+ EXPLICIT_CANARY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.source_version || '' }}
+ run: |
+ set -euo pipefail
+
+ git fetch origin --tags --prune --quiet
+
+ skip() {
+ echo "proceed=false" >> "$GITHUB_OUTPUT"
+ {
+ echo "## Nightly skipped"
+ echo ""
+ echo "$1"
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "Nightly skipped: $1"
+ }
+
+ if [ -n "${EXPLICIT_CANARY_VERSION:-}" ]; then
+ tag="canary/v${EXPLICIT_CANARY_VERSION}"
+ sha="$(git rev-list -n 1 "$tag" 2>/dev/null || true)"
+ if [ -z "$sha" ]; then
+ echo "Error: tag $tag does not exist." >&2
+ exit 1
+ fi
+ else
+ # Newest canary-tagged commit on master. Canary tags are pushed
+ # only after a successful canary publish, so tag presence is the
+ # green-publish signal. Walk master newest-first and stop at the
+ # first commit that carries a canary tag.
+ sha="$(grep -m1 -F \
+ -f <(git for-each-ref 'refs/tags/canary/v*' --format='%(objectname)') \
+ <(git rev-list origin/master -n 500) || true)"
+
+ if [ -z "$sha" ]; then
+ skip "No canary/v* tag found on the last 500 commits of master."
+ exit 0
+ fi
+
+ tag="$(git tag --points-at "$sha" | grep '^canary/v' | sort -V | tail -1)"
+ fi
+
+ canary_version="${tag#canary/v}"
+
+ existing_nightly="$(git tag --points-at "$sha" | grep '^nightly/v' | head -1 || true)"
+ if [ -n "$existing_nightly" ]; then
+ skip "Candidate \`$sha\` (canary \`$canary_version\`) already shipped as \`$existing_nightly\`."
+ exit 0
+ fi
+
+ echo "proceed=true" >> "$GITHUB_OUTPUT"
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
+ echo "canary_version=$canary_version" >> "$GITHUB_OUTPUT"
+ {
+ echo "## Nightly candidate"
+ echo ""
+ echo "- Source SHA: \`$sha\`"
+ echo "- Source canary: \`$canary_version\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ # Gate the promotion on the release smoke suite, run against the exact
+ # published canary artifact that would become tonight's nightly. Red smoke
+ # means no nightly tonight. Skipped for dry-run dispatches.
+ smoke_nightly:
+ needs: select_nightly
+ if: needs.select_nightly.outputs.proceed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
+ uses: ./.github/workflows/release-smoke.yml
+ with:
+ paperclip_version: ${{ needs.select_nightly.outputs.canary_version }}
+ artifact_name: nightly-release-smoke
+
+ publish_nightly:
+ needs: [select_nightly, smoke_nightly]
+ # Publish when smoke passed, or when smoke was deliberately skipped by a
+ # dry-run dispatch (the publish itself is a dry-run in that case).
+ if: >-
+ !cancelled() &&
+ needs.select_nightly.outputs.proceed == 'true' &&
+ (needs.smoke_nightly.result == 'success' ||
+ (needs.smoke_nightly.result == 'skipped' && github.event_name == 'workflow_dispatch' && inputs.dry_run))
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ environment: npm-canary
+ # The workflow-level concurrency group is per event, so a forced dispatch
+ # nightly could otherwise overlap the scheduled one and race it to the
+ # same next -nightly.N version. Serialize actual nightly publishes across
+ # events here; release.sh additionally refuses to double-publish a commit
+ # that already carries a nightly tag.
+ concurrency:
+ group: release-publish-nightly
+ cancel-in-progress: false
+ permissions:
+ contents: write
+ id-token: write
+ actions: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.select_nightly.outputs.sha }}
+ fetch-depth: 0
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 9.15.4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Validate release package manifest
+ run: node ./scripts/release-package-map.mjs check
+
+ - name: Install dependencies
+ run: pnpm install --no-frozen-lockfile
+
+ - name: Restore tracked install-time changes
+ run: git checkout -- pnpm-lock.yaml
+
+ - name: Configure git author
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Publish nightly
+ env:
+ GITHUB_ACTIONS: "true"
+ run: |
+ args=(nightly --skip-verify)
+ if [ "${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}" = "true" ]; then
+ args+=(--dry-run)
+ fi
+ ./scripts/release.sh "${args[@]}"
+
+ - name: Dump npm debug logs
+ if: failure()
+ run: |
+ shopt -s nullglob
+ for f in "$HOME"/.npm/_logs/*.log; do
+ echo "===== $f ====="
+ tail -n 300 "$f" | sed -E \
+ -e 's#((authorization|_authToken|_auth|node_auth_token|npm_token)"?[[:space:]]*[:=][[:space:]]*"?)(Bearer[[:space:]]+)?[^",[:space:]]+#\1***REDACTED***#Ig'
+ done
+
+ - name: Push nightly tag
+ if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
+ run: |
+ tag="$(git tag --points-at HEAD | grep '^nightly/v' | head -1)"
+ if [ -z "$tag" ]; then
+ echo "Error: no nightly tag points at HEAD after release." >&2
+ exit 1
+ fi
+ git push origin "refs/tags/${tag}"
+
+ # Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
+ # trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
+ # pushes), so dispatch the image build at the new tag explicitly.
+ - name: Build Docker images for the nightly tag
+ if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ tag="$(git tag --points-at HEAD | grep '^nightly/v' | head -1)"
+ {
+ echo "## Nightly published"
+ echo ""
+ echo "- Source SHA: \`${{ needs.select_nightly.outputs.sha }}\`"
+ echo "- Source canary: \`${{ needs.select_nightly.outputs.canary_version }}\`"
+ echo "- Published nightly: \`${tag#nightly/v}\`"
+ echo "- Docker build dispatched at \`${tag}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+ gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"
+
+ # ----- Stable lane ------------------------------------------------------
+
verify_stable:
- if: github.event_name == 'workflow_dispatch'
+ if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable'
uses: ./.github/workflows/release-verify.yml
with:
ref: ${{ inputs.source_ref }}
preview_stable:
- if: github.event_name == 'workflow_dispatch' && inputs.dry_run
+ if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -146,7 +365,7 @@ jobs:
./scripts/release.sh "${args[@]}"
publish_stable:
- if: github.event_name == 'workflow_dispatch' && !inputs.dry_run
+ if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run
needs: verify_stable
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -154,6 +373,7 @@ jobs:
permissions:
contents: write
id-token: write
+ actions: write
steps:
- name: Checkout repository
@@ -213,6 +433,25 @@ jobs:
fi
git push origin "refs/tags/${tag}"
+ # Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
+ # trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
+ # pushes), so dispatch the image build at the new tag explicitly. This
+ # is what moves Docker `:latest` and publishes the versioned stable
+ # image tags.
+ - name: Build Docker images for the stable tag
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ tag="$(git tag --points-at HEAD | grep '^v' | head -1)"
+ if gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"; then
+ echo "Dispatched docker.yml at ${tag}."
+ else
+ # Older source commits may predate docker.yml's workflow_dispatch
+ # trigger; the dispatch then fails while the npm release is
+ # already complete and correct.
+ echo "::warning::Could not dispatch docker.yml at ${tag}. Run docker.yml manually at that tag to publish the stable images."
+ fi
+
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
diff --git a/doc/CHANNELS.md b/doc/CHANNELS.md
new file mode 100644
index 00000000000..9aadd87ba78
--- /dev/null
+++ b/doc/CHANNELS.md
@@ -0,0 +1,79 @@
+# Release Channels
+
+Paperclip ships on four channels. Pick the one that matches your appetite for
+freshness versus stability — switching is just a matter of which version you
+install.
+
+| Channel | What it is | Updates | npm | Docker |
+| --- | --- | --- | --- | --- |
+| `stable` | The recommended release | every week or two | `paperclipai@latest` | `ghcr.io/paperclipai/paperclip:latest` |
+| `beta` | Release candidates soaking before stable | when promoted | *(coming soon)* | *(coming soon)* |
+| `nightly` | Yesterday's merges, smoke-tested as a unit | once a night | `paperclipai@nightly` | `ghcr.io/paperclipai/paperclip:nightly` |
+| `canary` | Every merge to `master`, as it happens | many times a day | `paperclipai@canary` | `ghcr.io/paperclipai/paperclip:canary` |
+
+## Choosing a channel
+
+**stable** is the right choice for almost everyone. It only moves when a
+release has been explicitly vetted and promoted by a maintainer.
+
+**nightly** is for people who want new features quickly but not the churn of
+tracking every merge. Once a night, the newest master build that published
+green is run through the full release smoke suite (real Docker container, real
+onboarding flow, browser-driven). Only if that passes does it ship as the
+nightly. If smoke fails, there is no nightly that night — the channel never
+ships a build that failed its checks.
+
+**canary** is the bleeding edge: it publishes on every merge to `master`.
+It is primarily the lane that continuously exercises our release automation,
+but it's available to anyone who wants the newest bits and accepts the risk.
+
+## Installing from a channel
+
+npm / npx:
+
+```bash
+npx paperclipai@latest onboard # stable
+npx paperclipai@nightly onboard
+npx paperclipai@canary onboard
+```
+
+Docker:
+
+```bash
+docker pull ghcr.io/paperclipai/paperclip:latest # stable
+docker pull ghcr.io/paperclipai/paperclip:nightly
+docker pull ghcr.io/paperclipai/paperclip:canary
+```
+
+Every image is also published as `:sha-` for exact pinning, and
+stable images additionally get `:YYYY.MDD.P` version tags.
+
+## Switching channels
+
+Channel choice is per-install: install from a different tag and you're on that
+channel. Moving forward (stable → nightly) is always safe. Moving backward
+(nightly → stable) can mean running an older schema than your data was created
+with — treat a downgrade like a restore and keep a backup of your data
+directory before switching down.
+
+## Reading version strings
+
+The version tells you which channel a build came from:
+
+- `2026.807.0` — stable, published Aug 7 2026
+- `2026.807.0-nightly.0` — nightly cut on Aug 7 2026
+- `2026.807.0-canary.4` — the fifth canary for the Aug 7 line
+
+A nightly republishes the exact source commit of a specific canary; the
+version dates the nightly cut, and the two share a source SHA (visible in the
+release job summary and as git tags on the commit).
+
+One quirk to be aware of: npm's semver ordering compares prerelease names
+alphabetically, so `-canary.N` sorts *below* `-nightly.N` for the same base
+version. This never matters when installing by dist-tag (the recommended way),
+only if you write version ranges by hand.
+
+## For maintainers
+
+The publishing mechanics, promotion flow, and release checklist live in
+[`RELEASING.md`](RELEASING.md).
diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md
index d21975c106f..157bf6526c3 100644
--- a/doc/RELEASE-AUTOMATION-SETUP.md
+++ b/doc/RELEASE-AUTOMATION-SETUP.md
@@ -141,6 +141,12 @@ Reasoning:
- every push to `master` should be able to publish a canary automatically
- no human approval should be required for canaries
+The scheduled nightly lane also publishes under `npm-canary`: it is the same
+trust level (fully automated, no human gate), its runs execute on `master` so
+the branch rule is satisfied, and reusing the environment means the nightly
+lane required no new environments and no npm trusted-publisher changes
+(publishing still happens from `release.yml`, see section 2.2).
+
## 6. Configure `npm-stable`
Recommended settings for `npm-stable`:
diff --git a/doc/RELEASING.md b/doc/RELEASING.md
index da4143dfae6..b623bd428a0 100644
--- a/doc/RELEASING.md
+++ b/doc/RELEASING.md
@@ -5,9 +5,13 @@ Maintainer runbook for shipping Paperclip across npm, GitHub, and the website-fa
The release model is now commit-driven:
1. Every push to `master` publishes a canary automatically.
-2. Stable releases are manually promoted from a chosen tested commit or canary tag.
-3. Stable release notes live in `releases/vYYYY.MDD.P.md`.
-4. Only stable releases get GitHub Releases.
+2. Once a night, the newest master commit with a green canary publish is
+ smoke-tested and republished as the nightly.
+3. Stable releases are manually promoted from a chosen tested commit or canary tag.
+4. Stable release notes live in `releases/vYYYY.MDD.P.md`.
+5. Only stable releases get GitHub Releases.
+
+The user-facing guide to the channels is [`CHANNELS.md`](CHANNELS.md).
## Versioning Model
@@ -15,12 +19,18 @@ Paperclip uses calendar versions that still fit semver syntax:
- stable: `YYYY.MDD.P`
- canary: `YYYY.MDD.P-canary.N`
+- nightly: `YYYY.MDD.P-nightly.N`
Examples:
- first stable on March 18, 2026: `2026.318.0`
- second stable on March 18, 2026: `2026.318.1`
- fourth canary for the `2026.318.1` line: `2026.318.1-canary.3`
+- first nightly cut on March 18, 2026: `2026.318.1-nightly.0`
+
+A nightly republishes the exact source commit of an existing canary; its
+version dates the nightly cut (the scheduled run's UTC date), not the source
+canary.
Important constraints:
@@ -41,16 +51,22 @@ Every stable release has four separate surfaces:
A stable release is done only when all four surfaces are handled.
-Canaries only cover the first two surfaces plus an internal traceability tag.
+Canaries and nightlies only cover the first two surfaces plus an internal
+traceability tag.
## Core Invariants
- canaries publish from `master`
+- nightlies republish a commit that already shipped a canary (the commit must
+ carry a `canary/v*` tag), and only after the release smoke suite passes
+ against that exact published canary
- stables publish from an explicitly chosen source ref
- tags point at the original source commit, not a generated release commit
- stable notes are always `releases/vYYYY.MDD.P.md`
-- canaries never create GitHub Releases
-- canaries never require changelog generation
+- canaries and nightlies never create GitHub Releases
+- canaries and nightlies never require changelog generation
+- Docker `:latest` moves only on stable releases; master builds publish
+ `:canary` and nightly builds publish `:nightly`
## TL;DR
@@ -78,6 +94,40 @@ npx paperclipai@canary onboard
npx paperclipai@canary onboard --data-dir "$(mktemp -d /tmp/paperclip-canary.XXXXXX)"
```
+### Nightly
+
+A scheduled job in [`.github/workflows/release.yml`](../.github/workflows/release.yml)
+runs once a night at 09:00 UTC.
+
+It:
+
+- selects the newest commit on `master` that carries a `canary/v*` tag (the
+ tag is pushed only after a successful canary publish, so it is the
+ green-publish signal)
+- skips with a job-summary reason when there is no new candidate or the
+ candidate already shipped as a nightly
+- runs the release smoke suite ([`release-smoke.yml`](../.github/workflows/release-smoke.yml))
+ against that exact published canary version — red smoke means no nightly
+ tonight
+- republishes the same source commit as `YYYY.MDD.P-nightly.N` under the npm
+ dist-tag `nightly` (the commit was already verified by its canary run, so
+ verification is not repeated)
+- creates and pushes the git tag `nightly/vYYYY.MDD.P-nightly.N`
+- dispatches [`docker.yml`](../.github/workflows/docker.yml) at that tag to
+ publish the `:nightly` images
+
+To force a nightly outside the schedule (recovery, or promoting a specific
+canary), dispatch `release.yml` with `channel: nightly`. Leave
+`source_version` empty for automatic selection, or set it to an exact
+canary version. `dry_run: true` previews the publish and skips smoke, the tag
+push, and the Docker dispatch.
+
+Users install nightlies with:
+
+```bash
+npx paperclipai@nightly onboard
+```
+
### Stable
Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Actions tab with the manual `workflow_dispatch` inputs.
@@ -86,6 +136,9 @@ Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the
Inputs:
+- `channel`
+ - `stable` (the default) for a stable release; `nightly` forces a nightly
+ run (see above)
- `source_ref`
- commit SHA, branch, or tag
- `stable_date`
@@ -113,8 +166,26 @@ The workflow:
- computes the next stable patch slot for the chosen UTC date
- publishes `YYYY.MDD.P` under npm dist-tag `latest`
- creates git tag `vYYYY.MDD.P`
+- dispatches [`docker.yml`](../.github/workflows/docker.yml) at that tag to
+ publish `:latest` and the versioned stable images
- creates or updates the GitHub Release from `releases/vYYYY.MDD.P.md`
+## Docker Image Tags
+
+[`docker.yml`](../.github/workflows/docker.yml) publishes both the self-hosted
+image and the `-cloud` variant with the same lane mapping:
+
+| Build ref | Tags |
+| --- | --- |
+| `master` push | `:canary`, `:sha-` |
+| `nightly/v*` tag | `:nightly`, `:sha-` |
+| `v*` tag (stable) | `:latest`, `:YYYY.MDD.P`, `:YYYY.MDD`, `:sha-` |
+
+Lane tags are pushed by release workflows using `GITHUB_TOKEN`, and GitHub
+suppresses push-triggered workflow runs for those pushes. The release jobs
+therefore dispatch `docker.yml` explicitly at the new tag ref; the tag
+mapping keys off `github.ref` either way.
+
## Local Commands
### Preview a canary locally
@@ -123,6 +194,15 @@ The workflow:
./scripts/release.sh canary --dry-run
```
+### Preview a nightly locally
+
+Requires HEAD to be a commit that already shipped a canary (it must carry a
+`canary/v*` tag):
+
+```bash
+./scripts/release.sh nightly --dry-run
+```
+
### Preview a stable locally
```bash
@@ -185,9 +265,13 @@ Automated browser smoke is also available:
```bash
gh workflow run release-smoke.yml -f paperclip_version=canary
+gh workflow run release-smoke.yml -f paperclip_version=nightly
gh workflow run release-smoke.yml -f paperclip_version=latest
```
+The nightly lane runs this same suite automatically against its candidate
+before publishing.
+
Minimum checks:
- `npx paperclipai@canary onboard` installs
@@ -224,6 +308,19 @@ Instead:
3. wait for the next automatic canary
4. rerun smoke testing
+### If the nightly skipped or failed
+
+A skipped nightly is working as designed — the job summary names the reason
+(no new green candidate, candidate already shipped, or red smoke). Nothing was
+published, so there is nothing to clean up.
+
+To recover after fixing the cause, either wait for the next scheduled run or
+force one: dispatch `release.yml` with `channel: nightly` (optionally pinning
+`source_version` to a specific canary).
+
+If the nightly published to npm but the tag push or Docker dispatch failed,
+push the `nightly/v*` tag manually and run `docker.yml` at that tag.
+
### If stable npm publish succeeds but tag push or GitHub release creation fails
This is a partial release. npm is already live.
diff --git a/scripts/__tests__/release-dry-run-notes.test.mjs b/scripts/__tests__/release-dry-run-notes.test.mjs
index 2e82a61a0fd..cbd7f460ad8 100644
--- a/scripts/__tests__/release-dry-run-notes.test.mjs
+++ b/scripts/__tests__/release-dry-run-notes.test.mjs
@@ -38,10 +38,24 @@ get_current_stable_version() { printf '2026.709.0\\n'; }
utc_date_iso() { printf '2026-07-10\\n'; }
list_public_package_info() { printf 'cli\\tpaperclipai\\t0.0.0\\n'; }
next_stable_version() { printf '2026.710.0\\n'; }
-next_canary_version() { printf '2026.710.0-canary.0\\n'; }
+next_prerelease_version() { printf '2026.710.0-%s.0\\n' "$1"; }
release_notes_file() { printf '%s/releases/v%s.md\\n' "$REPO_ROOT" "$1"; }
stable_tag_name() { printf 'v%s\\n' "$1"; }
-canary_tag_name() { printf 'canary/v%s\\n' "$1"; }
+prerelease_tag_name() { printf '%s/v%s\\n' "$1" "$2"; }
+require_channel_tag_at_head() {
+ if [ "\${FAKE_MISSING_CHANNEL_TAG:-}" = "$1" ]; then
+ echo "Error: HEAD has no $1/v* tag; this channel only publishes commits that already shipped a $1 release." >&2
+ exit 1
+ fi
+ echo "[fixture] require_channel_tag_at_head $1"
+}
+require_channel_tag_absent_at_head() {
+ if [ "\${FAKE_PRESENT_CHANNEL_TAG:-}" = "$1" ]; then
+ echo "Error: HEAD already shipped as $1/v2026.710.0-$1.0; delete that tag first if you really want to republish this commit on the $1 channel." >&2
+ exit 1
+ fi
+ echo "[fixture] require_channel_tag_absent_at_head $1"
+}
require_clean_worktree() { :; }
require_npm_publish_auth() { :; }
git_local_tag_exists() { return 1; }
@@ -108,7 +122,7 @@ exit 0
return { binDir, callLog, fixtureDir, script: join(scriptsDir, "release.sh") };
}
-function runRelease(args) {
+function runRelease(args, extraEnv = {}) {
const fixture = createReleaseFixture();
const result = spawnSync(fixture.script, args, {
cwd: fixture.fixtureDir,
@@ -117,6 +131,7 @@ function runRelease(args) {
...process.env,
PATH: `${fixture.binDir}:${process.env.PATH}`,
FAKE_CALL_LOG: fixture.callLog,
+ ...extraEnv,
},
});
@@ -148,3 +163,35 @@ test("stable publish still requires release notes before publish work starts", (
assert.doesNotMatch(result.output, /==> Step 2\/7: Building workspace artifacts/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
+
+test("nightly dry-run publishes under the nightly identity without release notes", () => {
+ const result = runRelease(["nightly", "--skip-verify", "--dry-run"]);
+
+ assert.equal(result.status, 42);
+ assert.match(result.output, /\[fixture\] require_channel_tag_at_head canary/);
+ assert.match(result.output, /Nightly version: 2026\.710\.0-nightly\.0/);
+ assert.match(result.output, /Dist-tag: nightly/);
+ assert.match(result.output, /Git tag: nightly\/v2026\.710\.0-nightly\.0/);
+ assert.doesNotMatch(result.output, /stable release notes file is required/);
+ assert.match(result.calls, /^pnpm build$/m);
+});
+
+test("nightly refuses commits that never shipped a canary", () => {
+ const result = runRelease(["nightly", "--skip-verify", "--dry-run"], {
+ FAKE_MISSING_CHANNEL_TAG: "canary",
+ });
+
+ assert.equal(result.status, 1);
+ assert.match(result.output, /HEAD has no canary\/v\* tag/);
+ assert.doesNotMatch(result.calls, /^pnpm /m);
+});
+
+test("nightly refuses commits that already shipped as a nightly", () => {
+ const result = runRelease(["nightly", "--skip-verify", "--dry-run"], {
+ FAKE_PRESENT_CHANNEL_TAG: "nightly",
+ });
+
+ assert.equal(result.status, 1);
+ assert.match(result.output, /HEAD already shipped as nightly\/v/);
+ assert.doesNotMatch(result.calls, /^pnpm /m);
+});
diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh
index 67c35919f0a..7e3b1b4de98 100644
--- a/scripts/release-lib.sh
+++ b/scripts/release-lib.sh
@@ -197,13 +197,24 @@ process.stdout.write(`${stableSlot}.${max + 1}`);
NODE
}
-next_canary_version() {
- local stable_version="$1"
- shift
+require_prerelease_channel() {
+ case "$1" in
+ canary|nightly) ;;
+ *) release_fail "unknown prerelease channel: $1" ;;
+ esac
+}
- node - "$stable_version" "$@" <<'NODE'
-const stable = process.argv[2];
-const packageNames = process.argv.slice(3);
+next_prerelease_version() {
+ local channel="$1"
+ local stable_version="$2"
+ shift 2
+
+ require_prerelease_channel "$channel"
+
+ node - "$channel" "$stable_version" "$@" <<'NODE'
+const channel = process.argv[2];
+const stable = process.argv[3];
+const packageNames = process.argv.slice(4);
const { execSync } = require("node:child_process");
const { readFileSync } = require("node:fs");
@@ -218,7 +229,7 @@ if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
}
}
-const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
+const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-${channel}\\.(\\d+)$`);
let max = -1;
for (const packageName of packageNames) {
@@ -249,10 +260,16 @@ for (const packageName of packageNames) {
}
}
-process.stdout.write(`${stable}-canary.${max + 1}`);
+process.stdout.write(`${stable}-${channel}.${max + 1}`);
NODE
}
+next_canary_version() {
+ local stable_version="$1"
+ shift
+ next_prerelease_version canary "$stable_version" "$@"
+}
+
release_notes_file() {
printf '%s/releases/v%s.md\n' "$REPO_ROOT" "$1"
}
@@ -261,8 +278,13 @@ stable_tag_name() {
printf 'v%s\n' "$1"
}
+prerelease_tag_name() {
+ require_prerelease_channel "$1"
+ printf '%s/v%s\n' "$1" "$2"
+}
+
canary_tag_name() {
- printf 'canary/v%s\n' "$1"
+ prerelease_tag_name canary "$1"
}
npm_package_version_exists() {
@@ -369,11 +391,14 @@ publish_package_to_npm() {
return 0
fi
- if [ "$dist_tag" != "canary" ]; then
- release_warn "Not retrying ${package_name}@${package_version} without provenance for dist-tag ${dist_tag}."
- rm -f "$publish_log"
- return 1
- fi
+ case "$dist_tag" in
+ canary|nightly) ;;
+ *)
+ release_warn "Not retrying ${package_name}@${package_version} without provenance for dist-tag ${dist_tag}."
+ rm -f "$publish_log"
+ return 1
+ ;;
+ esac
release_warn "Retrying ${package_name}@${package_version} once with npm provenance disabled."
if run_package_publish "$publish_tool" "$dist_tag" true; then
@@ -468,6 +493,33 @@ require_on_master_branch() {
fi
}
+# Promotion channels only republish commits that already shipped on the
+# previous lane, so the source commit must carry that lane's release tag.
+require_channel_tag_at_head() {
+ local channel="$1"
+
+ require_prerelease_channel "$channel"
+
+ if ! git -C "$REPO_ROOT" tag --points-at HEAD | grep -q "^${channel}/v"; then
+ release_fail "HEAD has no ${channel}/v* tag; this channel only publishes commits that already shipped a ${channel} release."
+ fi
+}
+
+# The inverse guard: a commit ships on a promotion channel at most once, so
+# concurrent or repeated runs cannot double-publish it. Delete the lane tag
+# first if a republish is genuinely intended.
+require_channel_tag_absent_at_head() {
+ local channel="$1"
+ local existing
+
+ require_prerelease_channel "$channel"
+
+ existing="$(git -C "$REPO_ROOT" tag --points-at HEAD | grep "^${channel}/v" | head -1 || true)"
+ if [ -n "$existing" ]; then
+ release_fail "HEAD already shipped as ${existing}; delete that tag first if you really want to republish this commit on the ${channel} channel."
+ fi
+}
+
require_npm_publish_auth() {
local dry_run="$1"
diff --git a/scripts/release-registry-versions.test.mjs b/scripts/release-registry-versions.test.mjs
index 2915dae665c..08566a699fc 100644
--- a/scripts/release-registry-versions.test.mjs
+++ b/scripts/release-registry-versions.test.mjs
@@ -195,3 +195,43 @@ test("next_stable_version falls back to npm view without a versions file", () =>
assert.equal(result.output, "2026.707.2");
assert.match(result.calls, /^npm view @paperclipai\/present versions --json$/m);
});
+
+test("next_prerelease_version counts per channel so nightly numbering ignores canaries", () => {
+ const fixture = makeFixture();
+ const versionsFile = join(fixture.fixtureDir, "versions.json");
+ writeFileSync(
+ versionsFile,
+ JSON.stringify({
+ "@paperclipai/a": ["2026.707.1-canary.4", "2026.707.1-nightly.0", "2026.707.1-nightly.1"],
+ }),
+ );
+
+ const result = runReleaseLibHelper(
+ 'next_prerelease_version nightly 2026.707.1 "@paperclipai/a"',
+ fixture,
+ { RELEASE_PACKAGE_VERSIONS_FILE: versionsFile },
+ );
+
+ assert.equal(result.status, 0);
+ assert.equal(result.output, "2026.707.1-nightly.2");
+ assert.doesNotMatch(result.calls, /npm view/);
+});
+
+test("next_prerelease_version rejects unknown channels", () => {
+ const fixture = makeFixture();
+ const result = runReleaseLibHelper(
+ 'next_prerelease_version weekly 2026.707.1 "@paperclipai/a"',
+ fixture,
+ );
+
+ assert.equal(result.status, 1);
+ assert.match(result.output, /unknown prerelease channel: weekly/);
+});
+
+test("prerelease_tag_name namespaces tags by channel", () => {
+ const fixture = makeFixture();
+ const result = runReleaseLibHelper("prerelease_tag_name nightly 2026.707.1-nightly.2", fixture);
+
+ assert.equal(result.status, 0);
+ assert.equal(result.output.trim(), "nightly/v2026.707.1-nightly.2");
+});
diff --git a/scripts/release.sh b/scripts/release.sh
index 8e42505af16..74e563db7a6 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -18,11 +18,12 @@ cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
- ./scripts/release.sh [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
+ ./scripts/release.sh [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
Examples:
./scripts/release.sh canary
./scripts/release.sh canary --date 2026-03-17 --dry-run
+ ./scripts/release.sh nightly --dry-run
./scripts/release.sh stable
./scripts/release.sh stable --date 2026-03-17 --dry-run
./scripts/release.sh stable --date 2026-03-18 --print-version
@@ -32,6 +33,10 @@ Notes:
zero-padded UTC day, and P is the same-day stable patch slot.
- Canary releases publish YYYY.MDD.P-canary.N under the npm dist-tag
"canary" and create the git tag canary/vYYYY.MDD.P-canary.N.
+ - Nightly releases republish a commit that already shipped a canary (HEAD
+ must carry a canary/v* tag) as YYYY.MDD.P-nightly.N under the npm
+ dist-tag "nightly", with the git tag nightly/vYYYY.MDD.P-nightly.N.
+ The version dates the nightly cut, not the source canary.
- Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and
create the git tag vYYYY.MDD.P.
- Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md.
@@ -85,7 +90,7 @@ set_cleanup_trap() {
while [ $# -gt 0 ]; do
case "$1" in
- canary|stable)
+ canary|nightly|stable)
if [ -n "$channel" ]; then
release_fail "only one release channel may be provided."
fi
@@ -146,9 +151,17 @@ DIST_TAG="latest"
if [ "$channel" = "canary" ]; then
require_on_master_branch
- TARGET_PUBLISH_VERSION="$(next_canary_version "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
+ TARGET_PUBLISH_VERSION="$(next_prerelease_version canary "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="canary"
- tag_name="$(canary_tag_name "$TARGET_PUBLISH_VERSION")"
+ tag_name="$(prerelease_tag_name canary "$TARGET_PUBLISH_VERSION")"
+elif [ "$channel" = "nightly" ]; then
+ # Nightly promotes an already-shipped canary commit, so it runs from a
+ # detached checkout of that commit rather than the master branch tip.
+ require_channel_tag_at_head canary
+ require_channel_tag_absent_at_head nightly
+ TARGET_PUBLISH_VERSION="$(next_prerelease_version nightly "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
+ DIST_TAG="nightly"
+ tag_name="$(prerelease_tag_name nightly "$TARGET_PUBLISH_VERSION")"
else
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
fi
@@ -193,11 +206,11 @@ release_info " Last stable tag: ${LAST_STABLE_TAG:-}"
release_info " Current stable version: $CURRENT_STABLE_VERSION"
release_info " Release date (UTC): $RELEASE_DATE"
release_info " Target stable version: $TARGET_STABLE_VERSION"
-if [ "$channel" = "canary" ]; then
- release_info " Canary version: $TARGET_PUBLISH_VERSION"
-else
- release_info " Stable version: $TARGET_PUBLISH_VERSION"
-fi
+case "$channel" in
+ canary) release_info " Canary version: $TARGET_PUBLISH_VERSION" ;;
+ nightly) release_info " Nightly version: $TARGET_PUBLISH_VERSION" ;;
+ *) release_info " Stable version: $TARGET_PUBLISH_VERSION" ;;
+esac
release_info " Dist-tag: $DIST_TAG"
release_info " Git tag: $tag_name"
if [ "$channel" = "stable" ]; then
@@ -358,14 +371,17 @@ release_info ""
if [ "$dry_run" = true ]; then
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
else
- if [ "$channel" = "canary" ]; then
- release_info "Published canary ${TARGET_PUBLISH_VERSION}."
- release_info "Install with: npx paperclipai@canary onboard"
- release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
- else
- release_info "Published stable ${TARGET_PUBLISH_VERSION}."
- release_info "Next steps:"
- release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
- release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
- fi
+ case "$channel" in
+ canary|nightly)
+ release_info "Published $channel ${TARGET_PUBLISH_VERSION}."
+ release_info "Install with: npx paperclipai@$channel onboard"
+ release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
+ ;;
+ *)
+ release_info "Published stable ${TARGET_PUBLISH_VERSION}."
+ release_info "Next steps:"
+ release_info " git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
+ release_info " ./scripts/create-github-release.sh $TARGET_STABLE_VERSION"
+ ;;
+ esac
fi
diff --git a/scripts/verify-release-registry-state.mjs b/scripts/verify-release-registry-state.mjs
index 50d4ad9464e..6ac110aa974 100644
--- a/scripts/verify-release-registry-state.mjs
+++ b/scripts/verify-release-registry-state.mjs
@@ -3,6 +3,9 @@
import { pathToFileURL } from "node:url";
const CANARY_VERSION_RE = /-canary\.\d+$/;
+const PRERELEASE_VERSION_RE = /-(?:canary|nightly)\.\d+$/;
+// Channels that publish prerelease versions and must never move `latest`.
+const PRERELEASE_CHANNELS = new Set(["canary", "nightly"]);
const EXIT_RETRIABLE_FAILURE = 1;
const EXIT_NON_RETRIABLE_FAILURE = 2;
@@ -10,6 +13,10 @@ export function isCanaryVersion(version) {
return CANARY_VERSION_RE.test(version);
}
+export function isPrereleaseVersion(version) {
+ return PRERELEASE_VERSION_RE.test(version);
+}
+
function createExitError(message, exitCode = EXIT_RETRIABLE_FAILURE) {
return Object.assign(new Error(message), { exitCode });
}
@@ -22,7 +29,7 @@ function usage() {
process.stderr.write(
[
"Usage:",
- " node scripts/verify-release-registry-state.mjs --channel --dist-tag --target-version --package [--package ...] [--allow-canary-latest]",
+ " node scripts/verify-release-registry-state.mjs --channel --dist-tag --target-version --package [--package ...] [--allow-canary-latest]",
"",
].join("\n"),
);
@@ -69,8 +76,8 @@ function parseArgs(argv) {
}
}
- if (options.channel !== "canary" && options.channel !== "stable") {
- throw createExitError("--channel must be canary or stable", EXIT_NON_RETRIABLE_FAILURE);
+ if (!PRERELEASE_CHANNELS.has(options.channel) && options.channel !== "stable") {
+ throw createExitError("--channel must be canary, nightly, or stable", EXIT_NON_RETRIABLE_FAILURE);
}
if (!options.distTag) {
@@ -284,19 +291,19 @@ export function verifyPackageRegistryProblems({
}
}
- if (channel === "canary") {
+ if (PRERELEASE_CHANNELS.has(channel)) {
const latestVersion = distTags.latest;
- if (latestVersion && isCanaryVersion(latestVersion) && !allowCanaryLatest) {
+ if (latestVersion && isPrereleaseVersion(latestVersion) && !allowCanaryLatest) {
problems.push(
createProblem(
- `${packageName}: latest dist-tag still resolves to canary ${latestVersion}; if that state is intentional, rerun the verification script directly with --allow-canary-latest`,
+ `${packageName}: latest dist-tag still resolves to prerelease ${latestVersion}; if that state is intentional, rerun the verification script directly with --allow-canary-latest`,
{ retriable: false },
),
);
}
- if (latestVersion && isCanaryVersion(latestVersion)) {
+ if (latestVersion && isPrereleaseVersion(latestVersion)) {
const latestManifest = requireManifest(
packageName,
latestVersion,
diff --git a/scripts/verify-release-registry-state.test.mjs b/scripts/verify-release-registry-state.test.mjs
index bf3b0b0cb24..ec5906d6765 100644
--- a/scripts/verify-release-registry-state.test.mjs
+++ b/scripts/verify-release-registry-state.test.mjs
@@ -6,6 +6,7 @@ import {
createManifestLookupKey,
fetchRegistryJson,
isCanaryVersion,
+ isPrereleaseVersion,
verifyPackageRegistryProblems,
verifyPackageRegistryState,
} from "./verify-release-registry-state.mjs";
@@ -15,6 +16,12 @@ test("isCanaryVersion matches release canaries", () => {
assert.equal(isCanaryVersion("2026.427.0"), false);
});
+test("isPrereleaseVersion matches canary and nightly versions", () => {
+ assert.equal(isPrereleaseVersion("2026.427.0-canary.3"), true);
+ assert.equal(isPrereleaseVersion("2026.427.0-nightly.0"), true);
+ assert.equal(isPrereleaseVersion("2026.427.0"), false);
+});
+
test("collectInternalDependencyProblems flags missing internal versions", () => {
const manifest = {
dependencies: {
@@ -192,7 +199,7 @@ test("verifyPackageRegistryState fails when canary latest is left in place by de
allowCanaryLatest: false,
}),
[
- "@paperclipai/plugin-e2b: latest dist-tag still resolves to canary 2026.425.0-canary.5; if that state is intentional, rerun the verification script directly with --allow-canary-latest",
+ "@paperclipai/plugin-e2b: latest dist-tag still resolves to prerelease 2026.425.0-canary.5; if that state is intentional, rerun the verification script directly with --allow-canary-latest",
"@paperclipai/plugin-e2b@2026.425.0-canary.5 via latest: dependencies requires @paperclipai/plugin-sdk@2026.425.0-canary.5, but npm does not expose that version",
],
);
@@ -225,7 +232,37 @@ test("verifyPackageRegistryProblems marks canary latest drift as non-retriable",
});
assert.equal(problems[0]?.retriable, false);
- assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to canary/);
+ assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to prerelease/);
+});
+
+test("verifyPackageRegistryProblems accepts the nightly channel and flags nightly latest drift", () => {
+ const packageDocsByName = new Map([
+ [
+ "@paperclipai/plugin-e2b",
+ {
+ "dist-tags": {
+ latest: "2026.425.0-nightly.1",
+ nightly: "2026.427.0-nightly.0",
+ },
+ versions: {
+ "2026.427.0-nightly.0": {},
+ },
+ },
+ ],
+ ]);
+
+ const problems = verifyPackageRegistryProblems({
+ packageName: "@paperclipai/plugin-e2b",
+ packageDoc: packageDocsByName.get("@paperclipai/plugin-e2b"),
+ packageDocsByName,
+ channel: "nightly",
+ distTag: "nightly",
+ targetVersion: "2026.427.0-nightly.0",
+ allowCanaryLatest: false,
+ });
+
+ assert.equal(problems[0]?.retriable, false);
+ assert.match(problems[0]?.message ?? "", /latest dist-tag still resolves to prerelease 2026\.425\.0-nightly\.1/);
});
test("verifyPackageRegistryState allows intentional canary latest but still checks dependencies", () => {
From 5ca752dc81cfb01f74282e1b562694079e1d0cf1 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 12:47:03 -0700
Subject: [PATCH 020/201] fix(server): raise company import zip upload limit to
1 GB and make it operator-configurable (#11184)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Company import/export lets an operator move a full company package
between instances, with the Import page uploading the package as one
compressed `.zip`
> - The server caps that upload at 128 MB, and real company packages
with attachments now exceed it — imports fail at the preview step
> - The failure message tells the user to use the CLI folder import, but
that path posts inline JSON capped at 64 MB, so the advice is a dead end
for exactly these packages
> - This pull request raises the zip upload cap to a 1 GB default, makes
it operator-configurable through an environment variable, scales the
decompression-bomb guards from the cap in effect, and replaces the
misleading hint
> - The benefit is that large real-world company packages import
successfully, and operators with unusual needs can tune the cap without
a code change
## Linked Issues or Issue Description
**What happened?**
A company import fails at the preview step with `Preview failed: Import
package exceeds 134217728 bytes`. The package is a valid Paperclip
export. Its compressed size is larger than the 128 MB server cap (one
reported package is 257 MB). The error panel suggests the CLI folder
import, but that path sends the package as one inline JSON body capped
at 64 MB, so it also fails.
**Expected behavior**
A valid company package of realistic size imports successfully through
the Import page. If a package is too large, the error must state the
limit clearly and suggest a step that can work.
**Steps to reproduce**
1. Export a company with enough attachments to make the compressed
package larger than 128 MB.
2. Open the Import page and upload the `.zip`.
3. Click "Preview import".
4. The preview fails with `Import package exceeds 134217728 bytes`.
**Deployment mode**
Reported from a managed deployment; the limit applies to all deployment
modes.
## What Changed
- Raise `PORTABLE_ZIP_UPLOAD_LIMIT_BYTES` from 128 MB to a 1 GB default
(`server/src/http/body-limits.ts`).
- Add the `PAPERCLIP_IMPORT_ZIP_MAX_BYTES` environment override. Invalid
or non-positive values fall back to the default.
- Scale the zip decompression-bomb guard from the configured cap at the
import route: the aggregate inflated ceiling is 4x the cap. The
per-entry ceiling stays at 512 MB because V8's string length limit
applies to an entry regardless (`server/src/routes/companies.ts`,
`packages/shared/src/portability-zip.ts`).
- Report the 422 limit error in MB instead of raw bytes.
- Replace the "use the CLI folder import for very large packages" hint
on preview failure with advice that works: re-export the package without
large attachments (`ui/src/pages/CompanyImport.tsx`).
- Update the stale comment in `ui/src/lib/import-preflight.ts` that made
the same CLI claim.
- Add tests for the new default, the env override, and the
invalid-override fallback.
## Verification
- `pnpm vitest run server/src/__tests__/body-limits.test.ts
packages/shared/src/portability-zip.test.ts
server/src/__tests__/company-portability-routes.test.ts
server/src/__tests__/company-portability.test.ts
server/src/__tests__/company-portability-import-batching.test.ts` — all
pass.
- `pnpm vitest run ui/src/pages/CompanyImport.test.tsx` — passes,
including the updated failure-panel copy assertion.
- `pnpm typecheck` — clean across the workspace.
- Manual: upload a `.zip` larger than the configured cap; the preview
fails with `Import package exceeds the 1024 MB upload limit` and the new
hint. A package between 128 MB and 1 GB now previews and imports.
## Risks
- Peak per-import memory rises with the cap: the upload is buffered in
memory and unzipped in one pass. A 1 GB compressed package can use
several GB transiently. Imports are instance-admin actions, so the
exposure is a deliberate operator action, not anonymous traffic.
Operators on small hosts can lower the cap with
`PAPERCLIP_IMPORT_ZIP_MAX_BYTES`.
- The aggregate bomb guard moves from a fixed 512 MB to 4x the
configured cap. It still bounds expansion far below what a decompression
bomb needs.
- No migration and no API shape change. The 422 message text changes; no
code matches on the old text.
## Model Used
- Claude Fable 5 (`claude-fable-5`) via Claude Code CLI, with extended
thinking and tool use (file edits, local test runs, live-instance
inspection).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
packages/shared/src/portability-zip.test.ts | 2 +-
packages/shared/src/portability-zip.ts | 11 +++---
server/src/__tests__/body-limits.test.ts | 38 ++++++++++++++++++++-
server/src/http/body-limits.ts | 16 ++++++++-
server/src/routes/companies.ts | 21 ++++++++++--
ui/src/lib/import-preflight.ts | 3 +-
ui/src/pages/CompanyImport.test.tsx | 2 +-
ui/src/pages/CompanyImport.tsx | 2 +-
8 files changed, 82 insertions(+), 13 deletions(-)
diff --git a/packages/shared/src/portability-zip.test.ts b/packages/shared/src/portability-zip.test.ts
index f128b701b85..a6b20335edd 100644
--- a/packages/shared/src/portability-zip.test.ts
+++ b/packages/shared/src/portability-zip.test.ts
@@ -270,7 +270,7 @@ describe("readZipArchive", () => {
it("bounds a highly compressible DEFLATE entry at the per-entry decompressed limit", async () => {
// Compresses tiny but expands to 8 KiB; a 1 KiB cap must reject it before it
- // materializes. Real packages sit far under the 256 MB production default.
+ // materializes. Real packages sit far under the 512 MB production default.
const bomb = new TextEncoder().encode("a".repeat(8 * 1024));
const archive = buildZip([{ path: "bomb.txt", bytes: bomb, method: 8 }], "paperclip-demo");
await expect(
diff --git a/packages/shared/src/portability-zip.ts b/packages/shared/src/portability-zip.ts
index ea531512ed1..f4b77e7e5de 100644
--- a/packages/shared/src/portability-zip.ts
+++ b/packages/shared/src/portability-zip.ts
@@ -20,10 +20,13 @@ const strictTextDecoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: tru
// malicious highly-compressible package cannot exhaust server memory: a
// per-entry ceiling (passed to zlib as maxOutputLength, so it fails before
// over-allocating) plus an aggregate ceiling across all entries. Both sit far
-// above any real company package (inline JSON was historically capped at 64MB)
-// yet far below what a bomb would need.
-export const MAX_ZIP_ENTRY_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
-export const MAX_ZIP_TOTAL_DECOMPRESSED_BYTES = 512 * 1024 * 1024;
+// above any real company package yet far below what a bomb would need. These
+// are only fallback defaults: the server import route passes explicit limits
+// scaled from its configured upload cap. The per-entry ceiling stays at 512 MB
+// even for large caps because a text or base64 entry beyond that cannot
+// materialize as a JS string (V8's string length limit) regardless.
+export const MAX_ZIP_ENTRY_DECOMPRESSED_BYTES = 512 * 1024 * 1024;
+export const MAX_ZIP_TOTAL_DECOMPRESSED_BYTES = 1024 * 1024 * 1024;
export const binaryContentTypeByExtension: Record = {
".gif": "image/gif",
diff --git a/server/src/__tests__/body-limits.test.ts b/server/src/__tests__/body-limits.test.ts
index 8164e32bd87..2749edcf7f0 100644
--- a/server/src/__tests__/body-limits.test.ts
+++ b/server/src/__tests__/body-limits.test.ts
@@ -1,12 +1,18 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_JSON_BODY_LIMIT,
PORTABLE_JSON_BODY_LIMIT,
PORTABLE_JSON_BODY_LIMIT_BYTES,
+ PORTABLE_ZIP_UPLOAD_LIMIT_BYTES,
} from "../http/body-limits.js";
describe("HTTP body limits", () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+ });
+
it("keeps the global JSON parser at the established ceiling", () => {
expect(DEFAULT_JSON_BODY_LIMIT).toBe("10mb");
});
@@ -16,4 +22,34 @@ describe("HTTP body limits", () => {
expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBe(64 * 1024 * 1024);
expect(PORTABLE_JSON_BODY_LIMIT_BYTES).toBeGreaterThan(10 * 1024 * 1024);
});
+
+ it("accepts large real-world compressed company packages by default", () => {
+ expect(PORTABLE_ZIP_UPLOAD_LIMIT_BYTES).toBe(1024 * 1024 * 1024);
+ });
+
+ it("lets operators override the zip upload cap via PAPERCLIP_IMPORT_ZIP_MAX_BYTES", async () => {
+ vi.stubEnv("PAPERCLIP_IMPORT_ZIP_MAX_BYTES", String(2 * 1024 * 1024 * 1024));
+ vi.resetModules();
+ const reloaded = await import("../http/body-limits.js");
+ expect(reloaded.PORTABLE_ZIP_UPLOAD_LIMIT_BYTES).toBe(2 * 1024 * 1024 * 1024);
+ });
+
+ it("falls back to the default when the env override is not a usable number", async () => {
+ for (const raw of ["unlimited", "0", "-5", "0.5"]) {
+ vi.stubEnv("PAPERCLIP_IMPORT_ZIP_MAX_BYTES", raw);
+ vi.resetModules();
+ const reloaded = await import("../http/body-limits.js");
+ expect(reloaded.PORTABLE_ZIP_UPLOAD_LIMIT_BYTES, `override ${JSON.stringify(raw)}`).toBe(
+ 1024 * 1024 * 1024,
+ );
+ }
+ });
+
+ it("clamps an oversized override so derived guard math cannot overflow", async () => {
+ vi.stubEnv("PAPERCLIP_IMPORT_ZIP_MAX_BYTES", String(Number.MAX_VALUE));
+ vi.resetModules();
+ const reloaded = await import("../http/body-limits.js");
+ expect(reloaded.PORTABLE_ZIP_UPLOAD_LIMIT_BYTES).toBe(64 * 1024 * 1024 * 1024);
+ expect(Number.isSafeInteger(reloaded.PORTABLE_ZIP_UPLOAD_LIMIT_BYTES * 4)).toBe(true);
+ });
});
diff --git a/server/src/http/body-limits.ts b/server/src/http/body-limits.ts
index 29c92b2baf9..6601dadb772 100644
--- a/server/src/http/body-limits.ts
+++ b/server/src/http/body-limits.ts
@@ -6,4 +6,18 @@ export const PORTABLE_JSON_BODY_LIMIT_BYTES = 64 * 1024 * 1024;
// application/zip) instead of an inflated inline JSON body. The compressed zip
// is roughly a third of the inline size, but the limit is kept generous so a
// large company package uploads in one request rather than truncating in transit.
-export const PORTABLE_ZIP_UPLOAD_LIMIT_BYTES = 128 * 1024 * 1024;
+// The whole upload is buffered in memory and unzipped in one pass, so this cap
+// bounds peak per-import memory (roughly the compressed size plus the inflated
+// package). Operators who need more (or less) headroom can override it with
+// PAPERCLIP_IMPORT_ZIP_MAX_BYTES; the import route scales its decompression-bomb
+// guards from whatever value is in effect.
+// The override is clamped to [1 byte, 64 GiB]: a fractional value would floor
+// to a zero-byte limit that rejects every upload, and an astronomically large
+// one would overflow the derived 4x decompression guard. 64 GiB is far beyond
+// what the in-memory import pipeline can serve anyway.
+const MAX_ZIP_UPLOAD_LIMIT_OVERRIDE_BYTES = 64 * 1024 * 1024 * 1024;
+const zipUploadLimitOverride = Math.floor(Number(process.env.PAPERCLIP_IMPORT_ZIP_MAX_BYTES));
+export const PORTABLE_ZIP_UPLOAD_LIMIT_BYTES =
+ Number.isFinite(zipUploadLimitOverride) && zipUploadLimitOverride >= 1
+ ? Math.min(zipUploadLimitOverride, MAX_ZIP_UPLOAD_LIMIT_OVERRIDE_BYTES)
+ : 1024 * 1024 * 1024;
diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts
index 19686def29b..186e05db563 100644
--- a/server/src/routes/companies.ts
+++ b/server/src/routes/companies.ts
@@ -6,7 +6,11 @@ import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable } from "@paperclipai/db";
import type { CompanyPortabilityImportResult } from "@paperclipai/shared";
-import { readZipArchive } from "@paperclipai/shared/portability-zip";
+import {
+ MAX_ZIP_ENTRY_DECOMPRESSED_BYTES,
+ MAX_ZIP_TOTAL_DECOMPRESSED_BYTES,
+ readZipArchive,
+} from "@paperclipai/shared/portability-zip";
import {
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
companyArtifactsQuerySchema,
@@ -131,7 +135,9 @@ async function resolveImportPayload(req: Request, res: Response): Promise>;
try {
- archive = await readZipArchive(zipBytes);
+ // Scale the bomb guards from the configured upload cap so a legitimately
+ // compressible package under the cap never trips them (~4x covers dense
+ // text; the per-entry ceiling is bounded by V8's string limit regardless).
+ archive = await readZipArchive(zipBytes, {
+ maxEntryDecompressedBytes: MAX_ZIP_ENTRY_DECOMPRESSED_BYTES,
+ maxTotalDecompressedBytes: Math.max(
+ MAX_ZIP_TOTAL_DECOMPRESSED_BYTES,
+ PORTABLE_ZIP_UPLOAD_LIMIT_BYTES * 4,
+ ),
+ });
} catch (error) {
throw badRequest(`Import package could not be read: ${errorMessage(error)}`);
}
diff --git a/ui/src/lib/import-preflight.ts b/ui/src/lib/import-preflight.ts
index df8c143df3e..fcc9e42bb7f 100644
--- a/ui/src/lib/import-preflight.ts
+++ b/ui/src/lib/import-preflight.ts
@@ -2,7 +2,8 @@ import type { CompanyPortabilityFileEntry } from "@paperclipai/shared";
// Inline imports post the whole parsed package as one JSON body, so oversized
// packages must be blocked before the request is built. Packages past this
-// limit go through the CLI folder import today; a blob-store relay is planned.
+// limit go through the zip upload path (a far higher, operator-configurable
+// server cap); a blob-store relay is planned for arbitrarily large migrations.
export const INLINE_IMPORT_MAX_BYTES = 56 * 1024 * 1024;
export function isBlobStoreFilePath(filePath: string): boolean {
diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx
index dd2fefcb97b..310f16203c2 100644
--- a/ui/src/pages/CompanyImport.test.tsx
+++ b/ui/src/pages/CompanyImport.test.tsx
@@ -420,7 +420,7 @@ describe("CompanyImport", () => {
await flushReact();
expect(container.textContent).toContain("Preview failed: stream disconnected");
- expect(container.textContent).toContain("Retry, or use the CLI folder import");
+ expect(container.textContent).toContain("Retry, or re-export the package without large attachments");
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ tone: "error" }));
// Changing the package supersedes the failed request: the error panel resets.
diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx
index 3339867d6de..a227894a68c 100644
--- a/ui/src/pages/CompanyImport.tsx
+++ b/ui/src/pages/CompanyImport.tsx
@@ -1779,7 +1779,7 @@ export function CompanyImport() {
{previewMutation.error instanceof Error
? previewMutation.error.message
: "the request did not complete."}{" "}
- Retry, or use the CLI folder import for very large packages.
+ Retry, or re-export the package without large attachments to shrink it.
)}
From 30f6999cbee35ff11727a01c65c316747881732a Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 13:19:33 -0700
Subject: [PATCH 021/201] fix(release-smoke): configurable readiness timeout
and diagnostics for slow containers (#11187)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem's nightly lane (#11006) gates every nightly
publish on the release smoke suite, which boots the published artifact
in a Docker container
> - The suite's first CI execution failed at the health readiness check:
the harness hard-codes a 90 second budget, but a CI container
cold-installs paperclipai from npm and initializes embedded postgres
with no warm caches
> - When the timeout expired with the container still running, the
harness printed no container logs, so the failure gave no diagnostics
> - This pull request makes the readiness budget configurable, raises it
for CI, and dumps container logs on timeout
> - The benefit is that the nightly gate measures the artifact, not the
runner's cold caches, and a red smoke run is diagnosable from its logs
## Linked Issues or Issue Description
**Subsystem affected**
Release smoke testing: `scripts/docker-onboard-smoke.sh`,
`.github/workflows/release-smoke.yml`.
**Problem or motivation**
Run 31426044332 (first forced nightly after #11006) failed in
`smoke_nightly` with `server did not become ready at
http://localhost:3232/api/health` after exactly 90 seconds. The
harness's readiness window is hard-coded to 90 attempts at 1 second.
Locally that works because the npm cache is warm; in CI the container
downloads the full package set and embedded postgres first. The timeout
path also printed no container logs when the container was still
running, so there was no way to see how far boot had progressed.
**Proposed solution**
Make the readiness budget an environment variable
(`SMOKE_READY_TIMEOUT_SECONDS`, default unchanged at 90 for local use),
set it to 420 in the CI workflow, and dump the last 150 container log
lines when the readiness check times out on a still-running container.
## What Changed
- `scripts/docker-onboard-smoke.sh`: `SMOKE_READY_TIMEOUT_SECONDS` env
var (default 90) replaces the hard-coded readiness budget; timeout with
a still-running container now prints the tail of `docker logs`
- `.github/workflows/release-smoke.yml`: sets
`SMOKE_READY_TIMEOUT_SECONDS=420` for CI runs
## Verification
- `bash -n` on the harness and YAML parse of the workflow
- The real proof is the next `channel: nightly` dispatch of
`release.yml`, which re-runs this suite in CI with the new budget
## Risks
- Low. The local default is unchanged; CI runs simply wait longer before
declaring failure, and a genuinely broken artifact still fails (with
logs now)
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use. Diagnosis from CI run logs; patch
model-authored under human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.github/workflows/release-smoke.yml | 1 +
.../release-verify-workflow.test.mjs | 19 ++++++++++++++++++-
scripts/docker-onboard-smoke.sh | 10 +++++++++-
3 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml
index 6429d9ef1cd..073be39d383 100644
--- a/.github/workflows/release-smoke.yml
+++ b/.github/workflows/release-smoke.yml
@@ -70,6 +70,7 @@ jobs:
HOST_PORT="${{ inputs.host_port }}" \
DATA_DIR="$RUNNER_TEMP/release-smoke-data" \
PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \
+ SMOKE_READY_TIMEOUT_SECONDS=420 \
SMOKE_DETACH=true \
SMOKE_METADATA_FILE="$metadata_file" \
./scripts/docker-onboard-smoke.sh
diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs
index b5911ab4e8b..841a002a286 100644
--- a/scripts/__tests__/release-verify-workflow.test.mjs
+++ b/scripts/__tests__/release-verify-workflow.test.mjs
@@ -17,13 +17,30 @@ test("release workflow delegates stable and canary verification to the reusable
releaseWorkflow,
/verify_canary:\n\s+if: github\.event_name == 'push'\n\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ github\.sha \}\}/,
);
+ // The stable lane is gated on the stable channel since the nightly lane
+ // was added; a `needs:` line (for example a preflight job) may sit between
+ // the gate and the delegation.
assert.match(
releaseWorkflow,
- /verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch'\n\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ inputs\.source_ref \}\}/,
+ /verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch' && inputs\.channel == 'stable'\n(?:\s+needs: [^\n]+\n)?\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ inputs\.source_ref \}\}/,
);
assert.doesNotMatch(releaseWorkflow, /verify_(?:canary|stable):[\s\S]*?pnpm test:run(?:\n|$)/);
});
+test("release smoke workflow extends the container readiness budget for CI", () => {
+ const smokeWorkflow = readWorkflow("release-smoke.yml");
+ const harness = readFileSync(path.join(repoRoot, "scripts/docker-onboard-smoke.sh"), "utf8");
+
+ // CI containers cold-install paperclipai and embedded postgres, so the
+ // workflow must extend the harness's local-default readiness budget.
+ assert.match(smokeWorkflow, /SMOKE_READY_TIMEOUT_SECONDS=\d+/);
+ const ciBudget = Number(smokeWorkflow.match(/SMOKE_READY_TIMEOUT_SECONDS=(\d+)/)[1]);
+ assert.ok(ciBudget >= 300, `CI readiness budget ${ciBudget}s should be at least 300s`);
+
+ assert.match(harness, /SMOKE_READY_TIMEOUT_SECONDS="\$\{SMOKE_READY_TIMEOUT_SECONDS:-\d+\}"/);
+ assert.match(harness, /wait_for_http "\$PAPERCLIP_PUBLIC_URL\/api\/health" "\$SMOKE_READY_TIMEOUT_SECONDS" 1/);
+});
+
test("release verify workflow covers the same split test surface as stable PR verification", () => {
const verifyWorkflow = readWorkflow("release-verify.yml");
diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh
index c2830ff0f5a..548931b5574 100755
--- a/scripts/docker-onboard-smoke.sh
+++ b/scripts/docker-onboard-smoke.sh
@@ -13,6 +13,11 @@ PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}"
PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}"
PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}"
SMOKE_AUTO_BOOTSTRAP="${SMOKE_AUTO_BOOTSTRAP:-true}"
+# Seconds to wait for /api/health after the container starts. The container
+# cold-installs paperclipai from npm and initializes embedded postgres before
+# it can serve health, so CI callers with no warm caches need far more than
+# the local default.
+SMOKE_READY_TIMEOUT_SECONDS="${SMOKE_READY_TIMEOUT_SECONDS:-90}"
SMOKE_ADMIN_NAME="${SMOKE_ADMIN_NAME:-Smoke Admin}"
SMOKE_ADMIN_EMAIL="${SMOKE_ADMIN_EMAIL:-smoke-admin@paperclip.local}"
SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-paperclip-smoke-password}"
@@ -63,6 +68,9 @@ wait_for_http() {
if ! container_is_running; then
echo "Smoke bootstrap failed: container $CONTAINER_NAME exited before readiness check completed" >&2
docker logs "$CONTAINER_NAME" >&2 || true
+ else
+ echo "Smoke bootstrap failed: $url not ready after ${attempts} attempts; container is still running. Last container logs:" >&2
+ docker logs --tail 150 "$CONTAINER_NAME" >&2 || true
fi
return 1
}
@@ -278,7 +286,7 @@ fi
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")"
COOKIE_JAR="$TMP_DIR/cookies.txt"
-if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" 90 1; then
+if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" "$SMOKE_READY_TIMEOUT_SECONDS" 1; then
echo "Smoke bootstrap failed: server did not become ready at $PAPERCLIP_PUBLIC_URL/api/health" >&2
exit 1
fi
From f94f6003c6b704ebcd8d63268baccf30b15e3337 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 13:51:49 -0700
Subject: [PATCH 022/201] fix(release-smoke): pin the smoke container to the
lan bind preset (#11189)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem's nightly lane gates every nightly on the
release smoke suite, which boots the published artifact in a Docker
container and drives real onboarding
> - The gate kept failing even after the readiness budget fix (#11187),
and the new container-log dump revealed the server was healthy but
listening on 127.0.0.1 inside the container, unreachable through
Docker's port mapping
> - `onboard --yes` without an explicit `--bind` prefers trusted-local
quickstart defaults: it writes a loopback bind into the instance config
and ignores the deployment env vars the harness passes, and that config
outranks `HOST` at runtime
> - This pull request pins the smoke container to the `lan` bind preset
and adds a wiring test for it
> - The benefit is a working nightly gate, verified end to end against a
real published canary
## Linked Issues or Issue Description
**Subsystem affected**
Release smoke testing: `docker/Dockerfile.onboard-smoke`,
`scripts/__tests__/release-verify-workflow.test.mjs`.
**Problem or motivation**
Nightly run 31428558684 failed in smoke with the server unreachable at
the mapped port for the full 420 second budget. The container logs
(captured thanks to #11187) show a fully booted server with `Bind
loopback (127.0.0.1)`. The harness sets `HOST=0.0.0.0` and the
deployment env vars, but `onboard --yes` without `--bind` deliberately
prefers trusted-local defaults, writes `bind: loopback` into the
instance config, and the config outranks `HOST` at runtime. A loopback
listener inside a container is invisible to the port mapping, so the
health check can never pass. This behavior predates the current stable,
so the harness was silently broken against every recent version — it
only surfaced now because the nightly lane is the suite's first CI
consumer.
**Proposed solution**
Pass `--bind lan` in the smoke container command (the flag is supported
by `latest` and canary alike; it selects the all-interfaces preset and
keeps the env-driven authenticated deployment), and pin the flag with a
wiring test so it cannot regress silently.
## What Changed
- `docker/Dockerfile.onboard-smoke`: the onboard command is now `onboard
--yes --bind lan --data-dir ...`, with a comment explaining why the flag
is load-bearing
- `scripts/__tests__/release-verify-workflow.test.mjs`: wiring test
asserting the smoke Dockerfile pins a non-loopback bind preset
## Verification
- Full local harness run against the real nightly candidate
`2026.810.0-canary.1`: container healthy, bind banner shows `lan
(0.0.0.0)`, authenticated bootstrap completed (admin created, bootstrap
invite accepted, board session verified), `/api/health` returns
`bootstrapStatus: ready`
- `node --test scripts/__tests__/release-verify-workflow.test.mjs`: 4
pass
- After merge: dispatch `release.yml` with `channel: nightly` to run the
gate end to end in CI
## Risks
- Low. The change only affects the smoke container. `--bind lan` inside
a container exposes the port to the container network only; reachability
from outside still goes through Docker's explicit port mapping
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use (CI log forensics, upstream source
tracing, local Docker reproduction and verification). All changes
model-authored under human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
docker/Dockerfile.onboard-smoke | 7 ++++++-
scripts/__tests__/release-verify-workflow.test.mjs | 9 +++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/docker/Dockerfile.onboard-smoke b/docker/Dockerfile.onboard-smoke
index 7b13756b81d..63689c6c8ae 100644
--- a/docker/Dockerfile.onboard-smoke
+++ b/docker/Dockerfile.onboard-smoke
@@ -37,4 +37,9 @@ WORKDIR /home/paperclip/workspace
EXPOSE 3100
USER paperclip
-CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --data-dir \"$PAPERCLIP_HOME\""]
+# --bind lan is required: `onboard --yes` without an explicit --bind prefers
+# trusted-local defaults, which write a loopback bind into the instance config
+# and ignore the deployment env vars. A loopback listener inside the container
+# is unreachable through Docker's port mapping, so the smoke health check can
+# never pass without this flag.
+CMD ["bash", "-lc", "set -euo pipefail; mkdir -p \"$PAPERCLIP_HOME\"; npx --yes \"paperclipai@${PAPERCLIPAI_VERSION}\" onboard --yes --bind lan --data-dir \"$PAPERCLIP_HOME\""]
diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs
index 841a002a286..d259376d237 100644
--- a/scripts/__tests__/release-verify-workflow.test.mjs
+++ b/scripts/__tests__/release-verify-workflow.test.mjs
@@ -27,6 +27,15 @@ test("release workflow delegates stable and canary verification to the reusable
assert.doesNotMatch(releaseWorkflow, /verify_(?:canary|stable):[\s\S]*?pnpm test:run(?:\n|$)/);
});
+test("onboard smoke container binds beyond loopback so the mapped port is reachable", () => {
+ const dockerfile = readFileSync(path.join(repoRoot, "docker/Dockerfile.onboard-smoke"), "utf8");
+
+ // `onboard --yes` without an explicit --bind prefers trusted-local
+ // defaults and writes a loopback bind, which Docker port mapping cannot
+ // reach. The smoke container must pin a non-loopback preset.
+ assert.match(dockerfile, /onboard --yes --bind lan/);
+});
+
test("release smoke workflow extends the container readiness budget for CI", () => {
const smokeWorkflow = readWorkflow("release-smoke.yml");
const harness = readFileSync(path.join(repoRoot, "scripts/docker-onboard-smoke.sh"), "utf8");
From 5a0985f80a6286c14c5bad3dec545b7aa0c96b3b Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 14:28:38 -0700
Subject: [PATCH 023/201] test(release-smoke): update onboarding spec for the
mission-first wizard (#11190)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem's nightly lane gates every nightly publish on
the release smoke suite, which drives real onboarding in a browser
against the published artifact
> - With the harness fixed (#11187, #11189), the gate reached the
Playwright suite for the first time in CI — and the spec still walks the
old onboarding wizard, so it fails at "Create your first agent" on every
current build
> - The wizard was redesigned to a mission-first five-step flow, and the
spec rotted silently because the suite never ran in CI before
> - This pull request rewrites the spec to drive the current wizard end
to end
> - The benefit is a smoke gate that actually tests today's product,
verified against a real published canary
## Linked Issues or Issue Description
**Subsystem affected**
Release smoke testing:
`tests/release-smoke/docker-auth-onboarding.spec.ts`.
**Problem or motivation**
Nightly run 31431273139 failed in the smoke Playwright suite: the spec
expects the old wizard step "Create your first agent", but current
builds show the redesigned mission-first flow (front door → company →
mission → team lead → connect model → review). The page snapshot in the
run artifact shows the "Define your mission" step where the spec
expected the agent step. Both retries failed identically — this is
deterministic spec drift, not flake.
**Proposed solution**
Rewrite the spec for the current flow: fill the company name, define the
mission directly (confirming creates the company), name the team lead,
hire it through the adapter step — the adapter environment probe reports
unhealthy in the CLI-less smoke container by design and must not block
the hire — then launch to the dashboard. Assert the company, the
ceo-role agent, and the company goal through the API. The first-task and
assignment-run assertions are removed together with the wizard flow that
created them.
## What Changed
- `tests/release-smoke/docker-auth-onboarding.spec.ts`: rewritten for
the mission-first wizard; sign-in and wizard-opening helpers and the
company-name step are unchanged
## Verification
- Full local run against the real nightly candidate: launched the smoke
container for `paperclipai@2026.810.0-canary.3` via
`scripts/docker-onboard-smoke.sh` (with the #11189 bind fix), then ran
`pnpm run test:release-smoke` against it — 1 passed (4.5s)
- After merge: dispatch `release.yml` with `channel: nightly` to run the
full gate in CI
## Risks
- Low. Test-only change. The spec now asserts less about first-task
creation because the wizard no longer creates a first task; if a
first-run trigger returns to onboarding, the spec should grow that
assertion back
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use (CI artifact forensics, UI source
tracing, local Docker + Playwright reproduction and verification). All
changes model-authored under human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.../docker-auth-onboarding.spec.ts | 80 +++++++++++++------
1 file changed, 57 insertions(+), 23 deletions(-)
diff --git a/tests/release-smoke/docker-auth-onboarding.spec.ts b/tests/release-smoke/docker-auth-onboarding.spec.ts
index d4494599e37..f094e9e537f 100644
--- a/tests/release-smoke/docker-auth-onboarding.spec.ts
+++ b/tests/release-smoke/docker-auth-onboarding.spec.ts
@@ -10,8 +10,11 @@ const ADMIN_PASSWORD =
"paperclip-smoke-password";
const COMPANY_NAME = `Release-Smoke-${Date.now()}`;
+const MISSION = "Ship a reliable release smoke suite for Paperclip.";
const AGENT_NAME = "CEO";
-const TASK_TITLE = "Release smoke task";
+// Seeded by the wizard's launch step (DEFAULT_TASK_TITLE in
+// ui/src/components/OnboardingWizard.tsx).
+const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan";
async function signIn(page: Page) {
await page.goto("/");
@@ -38,39 +41,50 @@ async function openOnboarding(page: Page) {
}
test.describe("Docker authenticated onboarding smoke", () => {
- test("logs in, completes onboarding, and triggers the first CEO run", async ({
+ test("logs in, completes onboarding, and hires the lead agent", async ({
page,
}) => {
await signIn(page);
await openOnboarding(page);
+ // Step 1: name the company.
await page.locator('input[placeholder="Acme Corp"]').fill(COMPANY_NAME);
await page.getByRole("button", { name: "Next" }).click();
+ // Step 2: define the mission directly; confirming creates the company.
await expect(
- page.locator("h3", { hasText: "Create your first agent" })
- ).toBeVisible({ timeout: 10_000 });
-
- await expect(page.locator('input[placeholder="CEO"]')).toHaveValue(AGENT_NAME);
- await page.getByRole("button", { name: "Next" }).click();
-
- await expect(
- page.locator("h3", { hasText: "Give it something to do" })
+ page.locator("h3", { hasText: "Define your mission" })
).toBeVisible({ timeout: 10_000 });
+ await page.getByRole("button", { name: "I know my mission" }).click();
await page
- .locator('input[placeholder="e.g. Research competitor pricing"]')
- .fill(TASK_TITLE);
+ .locator('textarea[placeholder="What is your team trying to achieve?"]')
+ .fill(MISSION);
+ await page.getByRole("button", { name: "Confirm mission" }).click();
+
+ // Step 3: name the team lead.
+ const leadNameInput = page.locator('input[placeholder="Chief of staff"]');
+ await expect(leadNameInput).toBeVisible({ timeout: 20_000 });
+ await leadNameInput.fill(AGENT_NAME);
await page.getByRole("button", { name: "Next" }).click();
- await expect(
- page.locator("h3", { hasText: "Ready to launch" })
- ).toBeVisible({ timeout: 10_000 });
- await expect(page.getByText(COMPANY_NAME)).toBeVisible();
- await expect(page.getByText(AGENT_NAME)).toBeVisible();
- await expect(page.getByText(TASK_TITLE)).toBeVisible();
+ // Step 4: keep the default adapter and hire the lead. The adapter
+ // environment test runs inside the smoke container, where no agent CLIs
+ // are installed; an unhealthy report is expected and must not block the
+ // hire. Allow generous time for the env probe + hire + auto-approval.
+ const heartbeatButton = page.getByRole("button", {
+ name: "Give it a heartbeat",
+ });
+ await expect(heartbeatButton).toBeVisible({ timeout: 10_000 });
+ await expect(heartbeatButton).toBeEnabled({ timeout: 30_000 });
+ await heartbeatButton.click();
- await page.getByRole("button", { name: "Create & Open Task" }).click();
- await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 });
+ // Step 5: review, then launch. "Get started" provisions the onboarding
+ // goal/project and navigates to the dashboard only on success.
+ const getStartedButton = page.getByRole("button", { name: "Get started" });
+ await expect(getStartedButton).toBeVisible({ timeout: 60_000 });
+ await expect(getStartedButton).toBeEnabled({ timeout: 10_000 });
+ await getStartedButton.click();
+ await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
const baseUrl = new URL(page.url()).origin;
@@ -95,6 +109,26 @@ test.describe("Docker authenticated onboarding smoke", () => {
expect(ceoAgent!.role).toBe("ceo");
expect(ceoAgent!.adapterType).not.toBe("process");
+ const goalsRes = await page.request.get(
+ `${baseUrl}/api/companies/${company!.id}/goals`
+ );
+ expect(goalsRes.ok()).toBe(true);
+ const goals = (await goalsRes.json()) as Array<{
+ id: string;
+ title: string;
+ level: string;
+ status: string;
+ }>;
+ expect(goals).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ title: MISSION,
+ level: "company",
+ status: "active",
+ }),
+ ])
+ );
+
const issuesRes = await page.request.get(
`${baseUrl}/api/companies/${company!.id}/issues`
);
@@ -104,9 +138,9 @@ test.describe("Docker authenticated onboarding smoke", () => {
title: string;
assigneeAgentId: string | null;
}>;
- const issue = issues.find((entry) => entry.title === TASK_TITLE);
- expect(issue).toBeTruthy();
- expect(issue!.assigneeAgentId).toBe(ceoAgent!.id);
+ const seededIssue = issues.find((entry) => entry.title === FIRST_TASK_TITLE);
+ expect(seededIssue).toBeTruthy();
+ expect(seededIssue!.assigneeAgentId).toBe(ceoAgent!.id);
await expect.poll(
async () => {
From 459469638eeab4af471edf65f80ed6e27bdd6d2b Mon Sep 17 00:00:00 2001
From: Nicky Leach
Date: Mon, 10 Aug 2026 16:12:30 -0700
Subject: [PATCH 024/201] feat(adapter-codex-local): add secure device-login
building blocks (#11097)
## Thinking Path
> - Paperclip connects AI agents to local and remote runtimes.
> - The Codex local adapter needs a safe device-login flow.
> - A future sandbox integration needs strict prompt validation, secret
protection, cleanup, and private credential storage.
> - This pull request adds tested building blocks for that flow.
> - The result gives a later Daytona integration a clear security
boundary.
## Linked Issues or Issue Description
No public issue covers this change.
**Problem or motivation**
The Codex local adapter has no safe, reusable flow to prove device login
inside an isolated sandbox.
**Proposed solution**
Add parser, runner, credential export, and proof helpers. Validate the
prompt, protect login data, store credentials in a private run-scoped
home, and dispose all sandbox resources.
**Alternatives considered**
Do not connect a production Daytona driver in this change. Use an
injected sandbox driver and focused tests first. This keeps the security
controls testable before live provider integration.
**Roadmap alignment**
The change extends the Codex local adapter. It does not add a core
Paperclip route or duplicate a planned core feature.
**Additional context**
The flow keeps the login URL, code, and token out of logs, results, and
errors. The proof home uses a company-scoped root and a run-scoped
private directory.
## What Changed
- Add a pure parser for the exact Codex device-login URL and one-time
code shape.
- Add a sandbox runner with prompt handling, timeout, cancellation, and
disposal.
- Add a credential export step with company scoping, path checks,
payload checks, private modes, locking, and cleanup.
- Add redacted device-login fixtures and focused tests for parsing,
secret redaction, runner outcomes, credential export, and cleanup.
## Verification
- Run `pnpm --filter @paperclipai/adapter-codex-local exec vitest run`.
- Run `pnpm --filter @paperclipai/adapter-codex-local exec tsc
--noEmit`.
- Review tests for strict URL and code validation, timeout,
cancellation, disposal, secret redaction, path safety, payload safety,
file modes, and cleanup.
## Risks
- This change provides building blocks, not a live Daytona proof.
- A later integration must connect the runner to a concrete sandbox
driver.
- Credential export depends on existing Codex authentication cache
helpers.
- Incorrect path or payload assumptions can reject valid credentials.
## Model Used
Codex, GPT-5, tool use, code execution, and repository review. The
Paperclip runtime controls the exact context window and reasoning mode.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
.../src/server/__fixtures__/README.md | 38 +++
.../server/__fixtures__/device-login-edge.txt | 1 +
.../__fixtures__/device-login-sample.txt | 5 +
.../src/server/device-login-export.test.ts | 284 +++++++++++++++++
.../src/server/device-login-export.ts | 290 ++++++++++++++++++
.../src/server/device-login-parse.test.ts | 212 +++++++++++++
.../src/server/device-login-parse.ts | 166 ++++++++++
.../src/server/device-login-runner.test.ts | 197 ++++++++++++
.../src/server/device-login-runner.ts | 206 +++++++++++++
9 files changed, 1399 insertions(+)
create mode 100644 packages/adapters/codex-local/src/server/__fixtures__/README.md
create mode 100644 packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt
create mode 100644 packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt
create mode 100644 packages/adapters/codex-local/src/server/device-login-export.test.ts
create mode 100644 packages/adapters/codex-local/src/server/device-login-export.ts
create mode 100644 packages/adapters/codex-local/src/server/device-login-parse.test.ts
create mode 100644 packages/adapters/codex-local/src/server/device-login-parse.ts
create mode 100644 packages/adapters/codex-local/src/server/device-login-runner.test.ts
create mode 100644 packages/adapters/codex-local/src/server/device-login-runner.ts
diff --git a/packages/adapters/codex-local/src/server/__fixtures__/README.md b/packages/adapters/codex-local/src/server/__fixtures__/README.md
new file mode 100644
index 00000000000..8cbb76777b5
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/__fixtures__/README.md
@@ -0,0 +1,38 @@
+# Device-login sample fixtures
+
+These fixtures hold redacted, real Codex device-login output. A capture step ran
+`codex login --device-auth` inside a Daytona sandbox and recorded the output. The
+capture step redacted every secret before it kept or posted the text. The parser
+tests read these fixtures. The tests never read a live secret.
+
+## Source
+
+- Capture date (UTC): `2026-08-08`.
+- Daytona image: `cr.app.daytona.io/sbox/daytona-6a8d60e245981dd72d0e647e6afae46e0fd7b8bdfdb29b08120747509a39dc33:daytona`.
+- Sandbox id: `3bd22e18-2103-4227-b918-6c375fd30b49` (region `us`).
+- Codex home: a new throwaway directory for each independent run.
+
+## Files
+
+| File | Row | Condition | Expected parse result |
+|---|---|---|---|
+| `device-login-sample.txt` | A — normal prompt | `timeout 60 codex login --device-auth` | a URL and a code |
+| `device-login-edge.txt` | D — error / retry | offline run with an unreachable local proxy | `null` |
+
+Two more rows from the capture are not committed as fixtures. Row B (timeout
+tail) printed no extra Codex line; the external `timeout` process ended the
+command with exit status `124`. Row C (`codex login --help`) and Row E
+(`codex --version`) are not device-login prompts.
+
+## Redaction
+
+The capture step transformed the real one-time code to question marks and kept
+the shape. So `device-login-sample.txt` holds `????-?????` in the code position.
+The real code alphabet is a Codex detail and the capture did not keep it. The
+parser matches the grounded structure of the code — four characters, a hyphen,
+then five characters — and does not invent an alphabet. The token class is bound
+to alphanumerics and the redaction sentinel `?`, so the committed sample parses
+to a code without a real secret in the repository.
+
+The capture step checked the final text for common credential field names and
+for an unredacted device-code pattern. It found no match.
diff --git a/packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt b/packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt
new file mode 100644
index 00000000000..a846c118206
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt
@@ -0,0 +1 @@
+Error logging in with device code: error sending request for url (https://auth.openai.com/api/accounts/deviceauth/)
diff --git a/packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt b/packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt
new file mode 100644
index 00000000000..9cf72aa8518
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt
@@ -0,0 +1,5 @@
+1. Open this link in your browser and sign in to your account
+https://auth.openai.com/codex/device
+2. Enter this one-time code (expires in 15 minutes)
+????-?????
+Device codes are a common phishing target. Never share this code.
diff --git a/packages/adapters/codex-local/src/server/device-login-export.test.ts b/packages/adapters/codex-local/src/server/device-login-export.test.ts
new file mode 100644
index 00000000000..616b23b1354
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/device-login-export.test.ts
@@ -0,0 +1,284 @@
+import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ MAX_AUTH_JSON_BYTES,
+ deriveProofHome,
+ installDeviceLoginCredential,
+ removeProofHome,
+ resolveProofHomeRoot,
+} from "./device-login-export.js";
+import { resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js";
+
+const COMPANY = "company-a";
+const RUN = "run-1234";
+const NEWER = "2026-07-09T02:00:00Z";
+const OLDER = "2026-07-09T01:00:00Z";
+const ACCOUNT = "acct-42";
+const OTHER_ACCOUNT = "acct-99";
+const TOKEN_SENTINEL = "SENTINEL_TOKEN_XYZ";
+
+describe("device-login credential export", () => {
+ const cleanupDirs: string[] = [];
+
+ afterEach(async () => {
+ while (cleanupDirs.length > 0) {
+ const dir = cleanupDirs.pop();
+ if (!dir) continue;
+ await chmod(dir, 0o700).catch(() => undefined);
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
+ }
+ });
+
+ async function makeInstanceRoot(): Promise {
+ const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-proof-"));
+ cleanupDirs.push(dir);
+ return dir;
+ }
+
+ function envFor(instanceHome: string, extra: Record = {}): NodeJS.ProcessEnv {
+ return { PAPERCLIP_HOME: instanceHome, PAPERCLIP_INSTANCE_ID: "default", ...extra };
+ }
+
+ function subscriptionAuth(input: { accountId: string; lastRefresh?: string; marker?: string }): Buffer {
+ const suffix = input.marker ?? input.accountId;
+ return Buffer.from(
+ JSON.stringify({
+ tokens: {
+ id_token: `id-token-${suffix}`,
+ access_token: `access-token-${suffix}`,
+ refresh_token: `${TOKEN_SENTINEL}-${suffix}`,
+ account_id: input.accountId,
+ },
+ ...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
+ }),
+ );
+ }
+
+ const noopLog = (_line: string): void => {};
+
+ it("deriveProofHome returns a unique, run-scoped path under a company-scoped root", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const root = resolveProofHomeRoot(env, COMPANY);
+ expect(root).toBe(
+ path.resolve(home, "instances", "default", "companies", COMPANY, "codex-device-login-proof"),
+ );
+ const a = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ const b = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ expect(a.startsWith(root + path.sep)).toBe(true);
+ expect(a).toContain(RUN);
+ expect(a).not.toBe(b); // unique per call
+ // Never the shared or the managed home.
+ expect(a).not.toBe(resolveSharedCodexHomeDir(env));
+ expect(a).not.toBe(resolveManagedCodexHomeDir(env, COMPANY));
+ });
+
+ it("export_creates_root_and_home_at_mode_0700", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ const root = resolveProofHomeRoot(env, COMPANY);
+ expect((await stat(root)).mode & 0o777).toBe(0o700);
+ expect((await stat(proofHome)).mode & 0o777).toBe(0o700);
+ });
+
+ it("export_seeds_empty_proof_home_with_mode_0600", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ const outcome = await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ expect(outcome).toBe("seeded");
+ const authPath = path.join(proofHome, "auth.json");
+ expect((await stat(authPath)).mode & 0o777).toBe(0o600);
+ const written = JSON.parse(await readFile(authPath, "utf8"));
+ expect(written.tokens.account_id).toBe(ACCOUNT);
+ });
+
+ it("export_rejects_default_shared_and_managed_home", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") });
+ const bytes = subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER });
+ const shared = resolveSharedCodexHomeDir(env);
+ const managed = resolveManagedCodexHomeDir(env, COMPANY);
+ const agentManaged = path.resolve(managed, "..", "agents", "agent-1", "codex-home");
+ for (const target of [shared, managed, agentManaged]) {
+ await expect(
+ installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: target, env, companyId: COMPANY, log: noopLog }),
+ ).rejects.toThrow();
+ }
+ });
+
+ it("export_rejects_symlink_or_non_regular_path", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const root = resolveProofHomeRoot(env, COMPANY);
+ await mkdir(root, { recursive: true, mode: 0o700 });
+ const bytes = subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER });
+
+ // A symlinked proof home is rejected.
+ const realDir = path.join(home, "elsewhere");
+ await mkdir(realDir, { recursive: true, mode: 0o700 });
+ const linkedHome = path.join(root, `${RUN}-linked`);
+ await symlink(realDir, linkedHome);
+ await expect(
+ installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: linkedHome, env, companyId: COMPANY, log: noopLog }),
+ ).rejects.toThrow();
+
+ // A proof home whose auth.json is a symlink is rejected.
+ const symAuthHome = path.join(root, `${RUN}-symauth`);
+ await mkdir(symAuthHome, { recursive: true, mode: 0o700 });
+ await symlink(path.join(home, "target.json"), path.join(symAuthHome, "auth.json"));
+ await expect(
+ installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: symAuthHome, env, companyId: COMPANY, log: noopLog }),
+ ).rejects.toThrow();
+ });
+
+ it("export_rejects_api_key_malformed_or_oversized_payload", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const apiKey = Buffer.from(JSON.stringify({ OPENAI_API_KEY: "sk-secret-key" }));
+ const malformed = Buffer.from("this is not json {");
+ const oversized = Buffer.concat([
+ subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
+ Buffer.alloc(MAX_AUTH_JSON_BYTES + 10, 0x20),
+ ]);
+ for (const bytes of [apiKey, malformed, oversized]) {
+ const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ await expect(
+ installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome, env, companyId: COMPANY, log: noopLog }),
+ ).rejects.toThrow();
+ // No file was written on a rejected payload.
+ await expect(stat(path.join(proofHome, "auth.json"))).rejects.toThrow();
+ }
+ });
+
+ it("export_updates_home_with_strictly_newer_same_identity", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ const authPath = path.join(proofHome, "auth.json");
+ await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "old" }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ const outcome = await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "new" }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ expect(outcome).toBe("updated");
+ const written = JSON.parse(await readFile(authPath, "utf8"));
+ expect(written.last_refresh).toBe(NEWER);
+ expect(written.tokens.refresh_token).toContain("new");
+ });
+
+ it("export_keeps_home_on_older_or_different_identity", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ const authPath = path.join(proofHome, "auth.json");
+ await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "keep" }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ // Older same identity: kept.
+ const olderOutcome = await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "older" }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ expect(olderOutcome).toBe("kept");
+ // Different identity, even newer: kept.
+ const otherOutcome = await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: OTHER_ACCOUNT, lastRefresh: NEWER, marker: "other" }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ expect(otherOutcome).toBe("kept");
+ const written = JSON.parse(await readFile(authPath, "utf8"));
+ expect(written.tokens.account_id).toBe(ACCOUNT);
+ expect(written.tokens.refresh_token).toContain("keep");
+ });
+
+ it("export_logs_contain_no_token_bytes", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ const logs: string[] = [];
+ await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: (line) => {
+ logs.push(line);
+ },
+ });
+ const haystack = logs.join("\n");
+ expect(haystack).not.toContain(TOKEN_SENTINEL);
+ expect(haystack).not.toContain(ACCOUNT);
+ });
+
+ it("cleanup_removes_proof_home_and_leaves_default_home_unchanged", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") });
+ // A sentinel in a fake shared/default home must survive the cleanup.
+ const shared = resolveSharedCodexHomeDir(env);
+ await mkdir(shared, { recursive: true, mode: 0o700 });
+ const sentinel = path.join(shared, "auth.json");
+ await writeFile(sentinel, JSON.stringify({ tokens: { account_id: "host", refresh_token: "host" } }), { mode: 0o600 });
+
+ const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN });
+ await installDeviceLoginCredential({
+ sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }),
+ proofHome,
+ env,
+ companyId: COMPANY,
+ log: noopLog,
+ });
+ expect((await lstat(proofHome)).isDirectory()).toBe(true);
+
+ await removeProofHome(proofHome, { env, companyId: COMPANY });
+ await expect(lstat(proofHome)).rejects.toThrow();
+ // The fake default home is untouched.
+ expect(await readFile(sentinel, "utf8")).toContain("host");
+ });
+
+ it("removeProofHome refuses a path outside the proof root", async () => {
+ const home = await makeInstanceRoot();
+ const env = envFor(home);
+ const outside = path.join(home, "not-a-proof-home");
+ await mkdir(outside, { recursive: true });
+ await expect(removeProofHome(outside, { env, companyId: COMPANY })).rejects.toThrow();
+ // The outside directory is untouched.
+ expect((await lstat(outside)).isDirectory()).toBe(true);
+ });
+});
diff --git a/packages/adapters/codex-local/src/server/device-login-export.ts b/packages/adapters/codex-local/src/server/device-login-export.ts
new file mode 100644
index 00000000000..b72f16749e8
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/device-login-export.ts
@@ -0,0 +1,290 @@
+import { chmod, lstat, mkdir, rm } from "node:fs/promises";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
+import { readSubscriptionAccountId, writeCodexAuthCacheEntry } from "./codex-auth-cache.js";
+import { copyBackCodexAuth } from "./codex-auth-copyback.js";
+import {
+ codexHomeHasUsableAuth,
+ resolveManagedCodexHomeDir,
+ resolveSharedCodexHomeDir,
+} from "./codex-home.js";
+
+// The credential export. It installs a device-login credential into a unique,
+// run-scoped, private proof home. It handles the empty-home first-login case and
+// a later strictly-newer, same-identity update. It removes the proof home on
+// every terminal path.
+//
+// Security (Control 2): the export never accepts an arbitrary host-home
+// argument. It derives a unique, run-scoped proof home under a dedicated
+// company-scoped root. It rejects the default, the shared, and every managed
+// Codex home. It calls `lstat` on the proof-home path and rejects a symlink or a
+// non-regular file. It creates the root and the proof home at mode 0700. It
+// stages and renames `auth.json` at mode 0600 under a directory lock (through the
+// reused seed writer and copy-back helpers). It enforces a bounded-size,
+// subscription-only auth shape before any write, so it rejects an API-key, a
+// malformed, and an oversized payload. It never logs token bytes.
+
+const PROOF_ROOT_DIR_NAME = "codex-device-login-proof";
+// A private directory (owner rwx only). 0o700 has no group or other bits.
+const PRIVATE_DIR_MODE = 0o700;
+// The managed Codex home directory always uses this name (see
+// `resolveManagedCodexHomeDir`). The export rejects any target with this name,
+// so a shared company home and a per-agent home are both refused.
+const MANAGED_HOME_DIR_NAME = "codex-home";
+const AUTH_FILE_NAME = "auth.json";
+
+// A bounded size for the credential payload. A real subscription `auth.json` is
+// a few kilobytes. The export refuses a larger payload before any parse or write.
+export const MAX_AUTH_JSON_BYTES = 64 * 1024;
+
+export type InstallDeviceLoginOutcome = "seeded" | "updated" | "kept";
+
+function nonEmpty(value: string | undefined): string | null {
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
+}
+
+/**
+ * Sanitizes one raw value to a single safe path segment. Rejects an empty value,
+ * a relative segment, a path separator, and a NUL byte, so the value can never
+ * become a path traversal. Returns the trimmed, safe segment.
+ */
+function requireSafeSegment(value: string, label: string): string {
+ const trimmed = typeof value === "string" ? value.trim() : "";
+ if (trimmed.length === 0) throw new Error(`device-login export: ${label} is empty`);
+ if (trimmed === "." || trimmed === "..") {
+ throw new Error(`device-login export: ${label} is a relative path segment`);
+ }
+ if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) {
+ throw new Error(`device-login export: ${label} contains a path separator`);
+ }
+ if (path.basename(trimmed) !== trimmed) {
+ throw new Error(`device-login export: ${label} is not a single path segment`);
+ }
+ return trimmed;
+}
+
+/** Reduces a run id to a safe, bounded path segment. Never throws. */
+function toSafeRunSegment(value: string | null): string {
+ const cleaned = (value ?? "").trim().replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 80);
+ return cleaned.length > 0 ? cleaned : "run";
+}
+
+/**
+ * Resolves the company-scoped proof-home root under the same isolation boundary
+ * as the managed Codex home. The root is always company-scoped, so a proof home
+ * can never cross a company boundary. `companyId` is required and is sanitized to
+ * a single safe path segment.
+ */
+export function resolveProofHomeRoot(env: NodeJS.ProcessEnv = process.env, companyId: string): string {
+ const safeCompanyId = requireSafeSegment(companyId, "companyId");
+ const instanceRoot = resolvePaperclipInstanceRootForAdapter({
+ homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
+ instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
+ env,
+ });
+ return path.resolve(instanceRoot, "companies", safeCompanyId, PROOF_ROOT_DIR_NAME);
+}
+
+export interface DeriveProofHomeInput {
+ env?: NodeJS.ProcessEnv;
+ companyId?: string;
+ runId?: string;
+}
+
+/**
+ * Returns a unique, run-scoped proof-home path under the company-scoped root.
+ * The path carries the run id and a fresh random suffix, so two calls never
+ * collide and every proof home is tied to its run.
+ */
+export function deriveProofHome(input: DeriveProofHomeInput = {}): string {
+ const env = input.env ?? process.env;
+ const companyId = requireSafeSegment(
+ input.companyId ?? nonEmpty(env.PAPERCLIP_COMPANY_ID) ?? "",
+ "companyId",
+ );
+ const runSegment = toSafeRunSegment(input.runId ?? nonEmpty(env.PAPERCLIP_RUN_ID));
+ const root = resolveProofHomeRoot(env, companyId);
+ return path.resolve(root, `${runSegment}-${randomUUID()}`);
+}
+
+/**
+ * Rejects a target that is the shared, the default, or a managed Codex home, or
+ * that is not strictly under the company-scoped proof root. This guard runs
+ * before any filesystem write, so the export never writes outside its own root.
+ */
+function assertProofHomeIsSafeTarget(
+ resolved: string,
+ env: NodeJS.ProcessEnv,
+ companyId: string,
+): void {
+ const shared = path.resolve(resolveSharedCodexHomeDir(env));
+ if (resolved === shared) {
+ throw new Error("device-login export: refused the shared or default Codex home");
+ }
+ const managed = path.resolve(resolveManagedCodexHomeDir(env, companyId));
+ if (resolved === managed) {
+ throw new Error("device-login export: refused the managed company Codex home");
+ }
+ if (path.basename(resolved) === MANAGED_HOME_DIR_NAME) {
+ throw new Error("device-login export: refused a managed Codex home");
+ }
+ const root = resolveProofHomeRoot(env, companyId);
+ if (!resolved.startsWith(root + path.sep)) {
+ throw new Error("device-login export: the proof home must be under the company-scoped proof root");
+ }
+}
+
+/**
+ * Enforces the bounded-size, subscription-only auth shape. Returns the
+ * subscription `account_id` on success. Rejects an empty, an oversized, an
+ * API-key, and a malformed payload. Never puts token bytes into the error.
+ */
+function assertUsableSubscriptionShape(bytes: Buffer): void {
+ if (bytes.length === 0) {
+ throw new Error("device-login export: refused an empty auth payload");
+ }
+ if (bytes.length > MAX_AUTH_JSON_BYTES) {
+ throw new Error("device-login export: refused an oversized auth payload");
+ }
+ const accountId = readSubscriptionAccountId(bytes);
+ if (!accountId) {
+ // Covers an API-key payload, a malformed payload, and an unusable payload.
+ throw new Error("device-login export: refused a non-subscription auth payload");
+ }
+}
+
+/**
+ * Ensures one directory exists and is private (mode 0700). Uses `lstat` (not
+ * `stat`), so the export never writes through a planted symlink. Fails closed
+ * when the existing path is a symlink or a non-directory.
+ */
+async function ensurePrivateDir(dir: string): Promise {
+ const existing = await lstat(dir).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (existing) {
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
+ throw new Error("device-login export: the proof-home path is a symlink or a non-directory");
+ }
+ await chmod(dir, PRIVATE_DIR_MODE);
+ return;
+ }
+ await mkdir(dir, { mode: PRIVATE_DIR_MODE });
+ await chmod(dir, PRIVATE_DIR_MODE);
+}
+
+/**
+ * Rejects an existing `auth.json` that is a symlink or a non-regular file. An
+ * absent file is the normal first-install case.
+ */
+async function assertAuthPathIsRegularOrAbsent(authPath: string): Promise {
+ const existing = await lstat(authPath).catch((error: NodeJS.ErrnoException) => {
+ if (error.code === "ENOENT") return null;
+ throw error;
+ });
+ if (existing && (existing.isSymbolicLink() || !existing.isFile())) {
+ throw new Error("device-login export: refused a symlink or a non-regular auth.json");
+ }
+}
+
+export interface InstallDeviceLoginCredentialInput {
+ /** The sandbox `auth.json` bytes read back from the login sandbox. */
+ sandboxAuthBytes: Buffer;
+ /** The run-scoped proof home from {@link deriveProofHome}. */
+ proofHome: string;
+ /** A non-leaking progress sink. It receives only fixed status lines. */
+ log: (line: string) => void | Promise;
+ env?: NodeJS.ProcessEnv;
+ companyId?: string;
+}
+
+/**
+ * Installs the sandbox credential into the run-scoped proof home. Seeds an empty
+ * home. Applies a strictly-newer, same-identity update to a non-empty home.
+ * Keeps the home otherwise. Enforces Control 2 before any write: the target
+ * safety guard, the auth-shape gate, and the path-safety guard all run first.
+ * Never logs token bytes.
+ */
+export async function installDeviceLoginCredential(
+ input: InstallDeviceLoginCredentialInput,
+): Promise {
+ const { sandboxAuthBytes, log } = input;
+ const env = input.env ?? process.env;
+ const companyId = requireSafeSegment(
+ input.companyId ?? nonEmpty(env.PAPERCLIP_COMPANY_ID) ?? "",
+ "companyId",
+ );
+ const proofHome = path.resolve(input.proofHome);
+
+ // 1. Target safety (pure path logic). Reject a shared, default, or managed
+ // home, or a path outside the proof root, before any filesystem work.
+ assertProofHomeIsSafeTarget(proofHome, env, companyId);
+
+ // 2. Auth-shape gate. Reject an empty, oversized, API-key, or malformed
+ // payload before any directory is created.
+ assertUsableSubscriptionShape(sandboxAuthBytes);
+
+ // 3. Path safety. Create the root and the proof home at mode 0700, each
+ // guarded by `lstat`.
+ const root = resolveProofHomeRoot(env, companyId);
+ await mkdir(path.dirname(root), { recursive: true });
+ await ensurePrivateDir(root);
+ await ensurePrivateDir(proofHome);
+
+ const authPath = path.join(proofHome, AUTH_FILE_NAME);
+ await assertAuthPathIsRegularOrAbsent(authPath);
+
+ // 4. Decide first install versus update.
+ const hadUsableAuth = await codexHomeHasUsableAuth(proofHome);
+ if (!hadUsableAuth) {
+ // First install: the copy-back predicate fails closed on an absent
+ // destination, so the seed writer fills the empty home. The seed writer
+ // stages and renames `auth.json` at mode 0600 under a directory lock.
+ const outcome = await writeCodexAuthCacheEntry({
+ sandboxAuthBytes,
+ cacheEntryPath: authPath,
+ log,
+ });
+ return outcome === "written" ? "seeded" : "kept";
+ }
+
+ // Update: install the credential only when it is strictly newer for the same
+ // subscription identity. `copyBackCodexAuth` runs the same decision predicate
+ // and the same 0600 staged rename under the directory lock.
+ const outcome = await copyBackCodexAuth({
+ readSandboxAuth: async () => sandboxAuthBytes,
+ hostAuthPath: authPath,
+ log,
+ env,
+ });
+ return outcome === "copied" ? "updated" : "kept";
+}
+
+export interface RemoveProofHomeInput {
+ env?: NodeJS.ProcessEnv;
+ companyId?: string;
+}
+
+/**
+ * Removes the proof home. Refuses a path outside the company-scoped proof root,
+ * so a cleanup can never delete the shared, the default, or a managed home. A
+ * missing proof home is a benign no-op.
+ */
+export async function removeProofHome(
+ proofHome: string,
+ input: RemoveProofHomeInput = {},
+): Promise {
+ const env = input.env ?? process.env;
+ const companyId = requireSafeSegment(
+ input.companyId ?? nonEmpty(env.PAPERCLIP_COMPANY_ID) ?? "",
+ "companyId",
+ );
+ const resolved = path.resolve(proofHome);
+ const root = resolveProofHomeRoot(env, companyId);
+ if (!resolved.startsWith(root + path.sep)) {
+ throw new Error("device-login export: refused to remove a path outside the proof root");
+ }
+ await rm(resolved, { recursive: true, force: true });
+}
diff --git a/packages/adapters/codex-local/src/server/device-login-parse.test.ts b/packages/adapters/codex-local/src/server/device-login-parse.test.ts
new file mode 100644
index 00000000000..cfd6fa9b981
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/device-login-parse.test.ts
@@ -0,0 +1,212 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { describe, expect, it } from "vitest";
+import { parseDeviceLoginPrompt } from "./device-login-parse.js";
+
+const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__");
+
+function readFixture(name: string): string {
+ return readFileSync(path.join(fixturesDir, name), "utf8");
+}
+
+const EXACT_URL = "https://auth.openai.com/codex/device";
+
+describe("parseDeviceLoginPrompt", () => {
+ it("parse_returns_url_and_code_from_sample", () => {
+ const result = parseDeviceLoginPrompt(readFixture("device-login-sample.txt"));
+ expect(result).not.toBeNull();
+ expect(result?.url).toBe(EXACT_URL);
+ // The committed sample keeps the capture-time redaction of the code. The
+ // parser extracts the four-hyphen-five structure without a real secret.
+ expect(result?.code).toBe("????-?????");
+ });
+
+ it("parse_returns_url_and_code_from_ansi_colored_output", () => {
+ // Codex CLI 0.128.0 and later add ANSI color (SGR) sequences around the URL
+ // and the code. A live sandbox run confirmed this format. The parser removes
+ // the Control Sequence Introducer sequences and reads the tokens the same as
+ // plain output.
+ const cyan = "\x1b[36m";
+ const bold = "\x1b[1m";
+ const reset = "\x1b[0m";
+ const text = [
+ "1. Open this link in your browser and sign in to your account",
+ `${cyan}${EXACT_URL}${reset}`,
+ "2. Enter this one-time code (expires in 15 minutes)",
+ `${bold}ABCD-EFGHJ${reset}`,
+ ].join("\n");
+ const result = parseDeviceLoginPrompt(text);
+ expect(result).not.toBeNull();
+ expect(result?.url).toBe(EXACT_URL);
+ expect(result?.code).toBe("ABCD-EFGHJ");
+ });
+
+ it("parse_returns_null_when_prompt_absent", () => {
+ const text = "Some unrelated log line\nNothing to see here\n";
+ expect(parseDeviceLoginPrompt(text)).toBeNull();
+ });
+
+ it("parse_returns_null_for_url_with_query_or_fragment", () => {
+ const withQuery = [
+ "Open this link",
+ "https://auth.openai.com/codex/device?foo=bar",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ const withFragment = [
+ "Open this link",
+ "https://auth.openai.com/codex/device#section",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ expect(parseDeviceLoginPrompt(withQuery)).toBeNull();
+ expect(parseDeviceLoginPrompt(withFragment)).toBeNull();
+ });
+
+ it("parse_returns_null_for_wrong_origin_or_path", () => {
+ const wrongOrigin = [
+ "Open this link",
+ "https://auth.example.com/codex/device",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ const wrongPath = [
+ "Open this link",
+ "https://auth.openai.com/codex/device/extra",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ const httpScheme = [
+ "Open this link",
+ "http://auth.openai.com/codex/device",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ expect(parseDeviceLoginPrompt(wrongOrigin)).toBeNull();
+ expect(parseDeviceLoginPrompt(wrongPath)).toBeNull();
+ expect(parseDeviceLoginPrompt(httpScheme)).toBeNull();
+ });
+
+ it("parse_returns_null_for_malformed_short_code", () => {
+ // Each input carries the code preamble after the URL, so the test exercises
+ // the code-structure check and not the preamble check.
+ const preamble = "2. Enter this one-time code (expires in 15 minutes)";
+ const shortCode = [EXACT_URL, preamble, "ABC-EFGHJ"].join("\n"); // 3 then 5
+ const longCode = [EXACT_URL, preamble, "ABCDE-EFGHJ"].join("\n"); // 5 then 5
+ const noHyphen = [EXACT_URL, preamble, "ABCDEFGHJ"].join("\n");
+ const noCode = [EXACT_URL, preamble, "no code on this line"].join("\n");
+ expect(parseDeviceLoginPrompt(shortCode)).toBeNull();
+ expect(parseDeviceLoginPrompt(longCode)).toBeNull();
+ expect(parseDeviceLoginPrompt(noHyphen)).toBeNull();
+ expect(parseDeviceLoginPrompt(noCode)).toBeNull();
+ });
+
+ it("parse_binds_code_to_url_and_ignores_a_code_before_the_url", () => {
+ // A code-shaped token appears before the URL. The parser reads the code only
+ // after the URL, so it uses the code that follows the URL.
+ const text = [
+ "WXYZ-98765",
+ "1. Open this link in your browser and sign in to your account",
+ EXACT_URL,
+ "2. Enter this one-time code (expires in 15 minutes)",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ const result = parseDeviceLoginPrompt(text);
+ expect(result).not.toBeNull();
+ expect(result?.code).toBe("ABCD-EFGHJ");
+ });
+
+ it("parse_returns_null_when_only_code_is_before_the_url", () => {
+ // The only code-shaped token sits before the URL. No code follows the URL,
+ // so the parser rejects the output instead of binding the earlier token.
+ const text = ["WXYZ-98765", "Open this link", EXACT_URL].join("\n");
+ expect(parseDeviceLoginPrompt(text)).toBeNull();
+ });
+
+ it("parse_returns_null_when_code_is_far_after_the_url", () => {
+ // A code-shaped token sits far below the URL, past the proximity window. The
+ // parser does not bind a distant token to the URL.
+ const filler = "unrelated log line\n".repeat(40);
+ const text = [EXACT_URL, filler, "ABCD-EFGHJ"].join("\n");
+ expect(parseDeviceLoginPrompt(text)).toBeNull();
+ });
+
+ it("parse_ignores_a_code_between_the_url_and_the_code_preamble", () => {
+ // A code-shaped token sits after the URL but before the "one-time code"
+ // preamble line. The parser anchors the code on the preamble, so it ignores
+ // the earlier token and returns the real code after the preamble.
+ const text = [
+ "1. Open this link in your browser and sign in to your account",
+ EXACT_URL,
+ "session id WXYZ-98765 for this attempt",
+ "2. Enter this one-time code (expires in 15 minutes)",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ const result = parseDeviceLoginPrompt(text);
+ expect(result).not.toBeNull();
+ expect(result?.code).toBe("ABCD-EFGHJ");
+ });
+
+ it("parse_ignores_a_code_that_shares_the_preamble_line", () => {
+ // A code-shaped token shares the preamble line with the "one-time code"
+ // phrase. The parser advances past the whole preamble line, so it ignores the
+ // token on that line and returns the code from the dedicated code line below.
+ const text = [
+ EXACT_URL,
+ "2. Enter this one-time code (ref WXYZ-98765, expires in 15 minutes)",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ const result = parseDeviceLoginPrompt(text);
+ expect(result).not.toBeNull();
+ expect(result?.code).toBe("ABCD-EFGHJ");
+ });
+
+ it("parse_returns_null_when_a_noise_line_follows_the_preamble", () => {
+ // A line that mixes a code-shaped token with other text sits right after the
+ // preamble line. The parser reads the code only from a dedicated code line, so
+ // it never surfaces the embedded token. It rejects the output instead. This
+ // proves the parser binds the code to the prompt-code line and not to any
+ // nearby code-shaped token.
+ const text = [
+ EXACT_URL,
+ "2. Enter this one-time code (expires in 15 minutes)",
+ "your session token is WXYZ-98765 here",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ expect(parseDeviceLoginPrompt(text)).toBeNull();
+ });
+
+ it("parse_returns_null_when_the_code_preamble_is_absent", () => {
+ // A valid code follows the URL, but the "one-time code" preamble is absent.
+ // The parser requires the preamble, so it rejects the output. This binds the
+ // code to the prompt structure and not to a bare code-shaped token.
+ const text = [
+ "1. Open this link in your browser and sign in to your account",
+ EXACT_URL,
+ "ABCD-EFGHJ",
+ ].join("\n");
+ expect(parseDeviceLoginPrompt(text)).toBeNull();
+ });
+
+ it("parse_ignores_token_like_text", () => {
+ // Token-like noise without the exact device URL must not yield a prompt.
+ const text = [
+ "tokens received",
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
+ "sk-proj-ABCD1234ABCD1234ABCD1234",
+ "ABCD-EFGHJ",
+ ].join("\n");
+ expect(parseDeviceLoginPrompt(text)).toBeNull();
+ });
+
+ it("parse_returns_null_for_edge_sample", () => {
+ // The grounded edge row carries a URL, but a wrong path and a wrong origin
+ // segment, so the parser rejects it.
+ expect(parseDeviceLoginPrompt(readFixture("device-login-edge.txt"))).toBeNull();
+ });
+
+ it("keeps the url and the code out of a thrown error", () => {
+ // A non-string input is a programming error, but the message must never
+ // carry secret-bearing input. The parser returns null instead of throwing.
+ // @ts-expect-error deliberate wrong type
+ expect(parseDeviceLoginPrompt(undefined)).toBeNull();
+ // @ts-expect-error deliberate wrong type
+ expect(parseDeviceLoginPrompt(12345)).toBeNull();
+ });
+});
diff --git a/packages/adapters/codex-local/src/server/device-login-parse.ts b/packages/adapters/codex-local/src/server/device-login-parse.ts
new file mode 100644
index 00000000000..86dc3fb83e4
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/device-login-parse.ts
@@ -0,0 +1,166 @@
+// The device-login output parser. It reads the Codex `login --device-auth`
+// output and returns the login URL and the one-time code, or null.
+//
+// Security (Control 1 — strict validation): the parser accepts only the exact
+// origin and path of the device-login URL. It rejects any query, any fragment, a
+// different origin, and a different path. It accepts only the short-code
+// structure `XXXX-XXXXX` (four characters, a hyphen, then five characters). The
+// parser never logs the URL, the code, or any input byte, and it keeps them out
+// of every thrown error. The parser is a pure function.
+
+export interface DeviceLoginPrompt {
+ url: string;
+ code: string;
+}
+
+// The one and only accepted device-login URL. The parser returns this exact
+// constant string on a match, so the output is never a caller-controlled value.
+export const DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device";
+
+// Codex CLI 0.128.0 and later wrap the login URL and the one-time code in ANSI
+// color sequences. A color sequence is a Control Sequence Introducer (CSI): the
+// ESC control byte, a `[`, zero or more parameter bytes (0x30-0x3F), zero or
+// more intermediate bytes (0x20-0x2F), and one final byte (0x40-0x7E). The
+// parser removes every CSI sequence first, so a colored URL or code reads the
+// same as a plain one. The strip only normalizes the input; the URL and the
+// code still pass the strict validation below.
+// eslint-disable-next-line no-control-regex
+const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
+
+// A candidate URL token is a run of non-space characters that starts with an
+// http or https scheme. The parser validates each candidate with the `URL`
+// class; the regular expression only splits tokens out of the text.
+const URL_TOKEN_RE = /https?:\/\/\S+/g;
+
+// Trailing punctuation that prose commonly puts right after a URL. The parser
+// strips only these characters. It never strips `?` or `#`, so a URL with a
+// query or a fragment stays malformed and the parser rejects it.
+const TRAILING_PUNCTUATION_RE = /[)\].,;:!]+$/;
+
+// The one-time code structure on a dedicated line: four characters, a hyphen,
+// then five characters, and nothing else on the line. The Codex prompt prints
+// the code alone on the line that comes right after the preamble line. So the
+// pattern matches the whole line. A code-shaped token that shares a line with
+// other text cannot match. The real code alphabet is a Codex detail that the
+// grounded capture did not keep (the capture redacted the code to `?`). So the
+// parser binds the token class to alphanumerics and the redaction sentinel `?`.
+const CODE_LINE_RE = /^([A-Za-z0-9?]{4}-[A-Za-z0-9?]{5})$/;
+
+// The prompt line that introduces the one-time code. The Codex prompt prints
+// the phrase "one-time code" on the line right before the code. The parser
+// anchors the code search on this phrase. So the code binds to the prompt line
+// that introduces it. A code-shaped token that sits between the URL and this
+// phrase cannot bind, because the parser reads the code only after the phrase.
+const CODE_PREAMBLE_RE = /one-time code/i;
+
+// The maximum number of characters between the end of the URL and the start of
+// the code preamble. The Codex prompt prints the preamble about one line after
+// the URL. The parser looks for the preamble only inside this window after the
+// URL. So a preamble far away in the output cannot bind to the URL.
+const MAX_URL_TO_PREAMBLE_GAP = 256;
+
+// The maximum number of characters after the end of the preamble line that the
+// parser reads for the code. The Codex prompt prints the code on the line right
+// after the preamble line. The parser looks for the code only inside this window
+// after the preamble line. The window is large enough for the real code line and
+// small enough to reject distant noise.
+const MAX_PREAMBLE_TO_CODE_GAP = 128;
+
+// The result of a URL search: the canonical URL and the index of the first
+// character after the matched URL token. The code search starts at this index.
+interface DeviceUrlMatch {
+ url: string;
+ end: number;
+}
+
+/**
+ * Returns the exact device-login URL and its end index when `text` holds it as
+ * a standalone token with the exact origin `https://auth.openai.com` and the
+ * exact path `/codex/device` and no query, fragment, or credentials. Returns
+ * null otherwise. Returns the canonical {@link DEVICE_LOGIN_URL} constant on a
+ * match. The `end` index is the position of the first character after the
+ * matched token in `text`.
+ */
+function findExactDeviceUrl(text: string): DeviceUrlMatch | null {
+ for (const match of text.matchAll(URL_TOKEN_RE)) {
+ const token = match[0];
+ const cleaned = token.replace(TRAILING_PUNCTUATION_RE, "");
+ let parsed: URL;
+ try {
+ parsed = new URL(cleaned);
+ } catch {
+ continue;
+ }
+ if (
+ parsed.protocol === "https:" &&
+ parsed.host === "auth.openai.com" &&
+ parsed.pathname === "/codex/device" &&
+ parsed.search === "" &&
+ parsed.hash === "" &&
+ parsed.username === "" &&
+ parsed.password === ""
+ ) {
+ return { url: DEVICE_LOGIN_URL, end: (match.index ?? 0) + token.length };
+ }
+ }
+ return null;
+}
+
+/**
+ * Returns the one-time code when `text` holds the code preamble after
+ * `fromIndex` and a dedicated code line after that preamble line. The parser
+ * first finds the "one-time code" preamble in a window after the URL. It then
+ * advances past the end of the preamble line and reads the first non-blank line
+ * after it. That line must hold only the `XXXX-XXXXX` code. So the code binds to
+ * the dedicated code line that the prompt prints right after the preamble line.
+ * A code-shaped token between the URL and the preamble cannot bind. A code-shaped
+ * token that shares the preamble line cannot bind. A code-shaped token inside a
+ * line that also holds other text cannot bind. Returns null when the preamble is
+ * absent, when the preamble has no line break after it, or when the first
+ * non-blank line after the preamble line is not a code line.
+ */
+function findShortCode(text: string, fromIndex: number): string | null {
+ const preambleWindow = text.slice(fromIndex, fromIndex + MAX_URL_TO_PREAMBLE_GAP);
+ const preambleMatch = CODE_PREAMBLE_RE.exec(preambleWindow);
+ if (!preambleMatch) return null;
+ // Advance to the end of the preamble line. The code sits on the next line, so
+ // the parser reads the code only after the preamble line ends. A code-shaped
+ // token on the preamble line cannot bind.
+ const preambleEnd = fromIndex + preambleMatch.index + preambleMatch[0].length;
+ const lineBreak = text.indexOf("\n", preambleEnd);
+ if (lineBreak === -1) return null;
+ const codeWindow = text.slice(lineBreak + 1, lineBreak + 1 + MAX_PREAMBLE_TO_CODE_GAP);
+ // Read the first non-blank line after the preamble line. It must hold only the
+ // code. So the parser binds the code to the dedicated code line, and it rejects
+ // a line that mixes the code with other text.
+ for (const line of codeWindow.split("\n")) {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) continue;
+ const match = CODE_LINE_RE.exec(trimmed);
+ return match ? match[1] : null;
+ }
+ return null;
+}
+
+/**
+ * Parses Codex device-login output. Removes ANSI color sequences first, so
+ * colored output from Codex CLI 0.128.0 and later reads the same as plain
+ * output. The parser reads the URL first, then finds the "one-time code"
+ * preamble after the URL, then reads the code from the dedicated code line right
+ * after the preamble line. So the code binds to the prompt line that introduces
+ * it, and a code-shaped token between the URL and the preamble, on the preamble
+ * line, or mixed with other text on a later line, cannot form a prompt. Returns
+ * the login URL and the one-time code when both are present and valid. Returns
+ * null for any other input, including a non-string input, an absent prompt, a URL
+ * with a query or a fragment, a wrong origin or path, and a malformed short code.
+ * Never throws on input, and never puts the URL or the code into a log or an error.
+ */
+export function parseDeviceLoginPrompt(text: string): DeviceLoginPrompt | null {
+ if (typeof text !== "string" || text.length === 0) return null;
+ const clean = text.replace(ANSI_CSI_RE, "");
+ const urlMatch = findExactDeviceUrl(clean);
+ if (!urlMatch) return null;
+ const code = findShortCode(clean, urlMatch.end);
+ if (!code) return null;
+ return { url: urlMatch.url, code };
+}
diff --git a/packages/adapters/codex-local/src/server/device-login-runner.test.ts b/packages/adapters/codex-local/src/server/device-login-runner.test.ts
new file mode 100644
index 00000000000..7a149581499
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/device-login-runner.test.ts
@@ -0,0 +1,197 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ CODEX_DEVICE_LOGIN_COMMAND,
+ runDeviceLogin,
+ type SandboxLoginDriver,
+} from "./device-login-runner.js";
+
+const REAL_SHAPED_URL = "https://auth.openai.com/codex/device";
+const REAL_SHAPED_CODE = "WXYZ-12345";
+const TOKEN_SENTINEL = "SENTINEL_TOKEN_ABC123";
+const AUTH_BYTES = Buffer.from(
+ JSON.stringify({ tokens: { account_id: "acc", refresh_token: TOKEN_SENTINEL } }),
+);
+
+interface FakeDriverOptions {
+ chunks?: string[];
+ exitCode?: number;
+ hang?: boolean;
+ execError?: Error;
+ readError?: Error;
+ authBytes?: Buffer;
+}
+
+function createFakeDriver(options: FakeDriverOptions = {}) {
+ const disposeCalls = { count: 0 };
+ const driver: SandboxLoginDriver = {
+ async execStreaming(_command, onStdout) {
+ if (options.execError) throw options.execError;
+ for (const chunk of options.chunks ?? []) {
+ onStdout(chunk);
+ }
+ if (options.hang) {
+ // Never resolve. The runner must fall back to its timeout or signal.
+ await new Promise(() => {});
+ }
+ return { exitCode: options.exitCode ?? 0 };
+ },
+ async readFile() {
+ if (options.readError) throw options.readError;
+ return options.authBytes ?? AUTH_BYTES;
+ },
+ async dispose() {
+ disposeCalls.count += 1;
+ },
+ };
+ return { driver, disposeCalls };
+}
+
+describe("runDeviceLogin", () => {
+ it("runner_reports_prompt_then_success", async () => {
+ const { driver, disposeCalls } = createFakeDriver({
+ // The prompt spans two chunks; the runner must join them and fire once.
+ // The chunk boundary falls inside the prompt, after the code preamble.
+ chunks: [
+ `1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n`,
+ `${REAL_SHAPED_CODE}\nDone.\n`,
+ ],
+ exitCode: 0,
+ });
+ const onPrompt = vi.fn();
+ const onCredential = vi.fn();
+ const result = await runDeviceLogin(driver, {
+ onPrompt,
+ onCredential,
+ authPath: "/home/.codex/auth.json",
+ timeoutMs: 1000,
+ });
+ expect(result.outcome).toBe("success");
+ expect(result.exitCode).toBe(0);
+ expect(result.promptSurfaced).toBe(true);
+ expect(onPrompt).toHaveBeenCalledTimes(1);
+ expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE });
+ expect(onCredential).toHaveBeenCalledTimes(1);
+ expect(onCredential).toHaveBeenCalledWith(AUTH_BYTES);
+ expect(disposeCalls.count).toBe(1);
+ });
+
+ it("runner_surfaces_prompt_after_a_large_pre_prompt_stream", async () => {
+ // The sandbox streams a large volume of output before the prompt. The runner
+ // bounds the parse buffer, so the memory stays limited. The prompt arrives at
+ // the end of the stream, so the trailing window still holds it and the runner
+ // surfaces it.
+ const noise = "unrelated sandbox log line\n".repeat(20000);
+ const { driver } = createFakeDriver({
+ chunks: [
+ noise,
+ `1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n${REAL_SHAPED_CODE}\nDone.\n`,
+ ],
+ exitCode: 0,
+ });
+ const onPrompt = vi.fn();
+ const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 });
+ expect(result.outcome).toBe("success");
+ expect(result.promptSurfaced).toBe(true);
+ expect(onPrompt).toHaveBeenCalledTimes(1);
+ expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE });
+ });
+
+ it("runner_surfaces_prompt_at_the_start_of_one_large_chunk", async () => {
+ // One stdout callback supplies more than the retained-buffer limit. The
+ // prompt sits near the start, and a large volume of output follows it. The
+ // runner parses the whole chunk before it trims the retained window, so it
+ // still finds the early prompt and never drops it.
+ const trailingNoise = "unrelated sandbox log line\n".repeat(20000);
+ const { driver } = createFakeDriver({
+ chunks: [
+ `1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n${REAL_SHAPED_CODE}\nDone.\n${trailingNoise}`,
+ ],
+ exitCode: 0,
+ });
+ const onPrompt = vi.fn();
+ const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 });
+ expect(result.outcome).toBe("success");
+ expect(result.promptSurfaced).toBe(true);
+ expect(onPrompt).toHaveBeenCalledTimes(1);
+ expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE });
+ });
+
+ it("runner_disposes_sandbox_on_timeout", async () => {
+ const { driver, disposeCalls } = createFakeDriver({ hang: true });
+ const onPrompt = vi.fn();
+ const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 20 });
+ expect(result.outcome).toBe("timeout");
+ expect(disposeCalls.count).toBe(1);
+ });
+
+ it("runner_disposes_sandbox_on_cancellation", async () => {
+ const { driver, disposeCalls } = createFakeDriver({ hang: true });
+ const controller = new AbortController();
+ const onPrompt = vi.fn();
+ const promise = runDeviceLogin(driver, {
+ onPrompt,
+ timeoutMs: 5000,
+ signal: controller.signal,
+ });
+ controller.abort();
+ const result = await promise;
+ expect(result.outcome).toBe("cancelled");
+ expect(disposeCalls.count).toBe(1);
+ });
+
+ it("runner_reports_failure_on_nonzero_exit", async () => {
+ const { driver, disposeCalls } = createFakeDriver({ exitCode: 7 });
+ const onPrompt = vi.fn();
+ const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 });
+ expect(result.outcome).toBe("failure");
+ expect(result.exitCode).toBe(7);
+ expect(disposeCalls.count).toBe(1);
+ });
+
+ it("runner_logs_and_result_contain_no_url_code_or_token_sentinel", async () => {
+ const logs: string[] = [];
+ const { driver } = createFakeDriver({
+ chunks: [
+ `${REAL_SHAPED_URL}\n${REAL_SHAPED_CODE}\n`,
+ `refresh_token=${TOKEN_SENTINEL}\n`,
+ ],
+ exitCode: 0,
+ });
+ const result = await runDeviceLogin(driver, {
+ onPrompt: () => {},
+ timeoutMs: 1000,
+ log: (line) => {
+ logs.push(line);
+ },
+ });
+ const haystack = `${logs.join("\n")}\n${JSON.stringify(result)}`;
+ expect(haystack).not.toContain(REAL_SHAPED_URL);
+ expect(haystack).not.toContain(REAL_SHAPED_CODE);
+ expect(haystack).not.toContain(TOKEN_SENTINEL);
+ });
+
+ it("runner_error_messages_contain_no_url_or_code", async () => {
+ const { driver, disposeCalls } = createFakeDriver({
+ // A driver error whose message embeds secret-bearing text. The runner must
+ // never let that message reach its own thrown error.
+ execError: new Error(`network failure while streaming ${REAL_SHAPED_URL} ${REAL_SHAPED_CODE}`),
+ });
+ let caught: unknown;
+ try {
+ await runDeviceLogin(driver, { onPrompt: () => {}, timeoutMs: 1000 });
+ } catch (error) {
+ caught = error;
+ }
+ expect(caught).toBeInstanceOf(Error);
+ const message = (caught as Error).message;
+ expect(message).not.toContain(REAL_SHAPED_URL);
+ expect(message).not.toContain(REAL_SHAPED_CODE);
+ // The driver is still disposed on the error path.
+ expect(disposeCalls.count).toBe(1);
+ });
+
+ it("exposes the default login command", () => {
+ expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("codex");
+ expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("--device-auth");
+ });
+});
diff --git a/packages/adapters/codex-local/src/server/device-login-runner.ts b/packages/adapters/codex-local/src/server/device-login-runner.ts
new file mode 100644
index 00000000000..2eccde880bf
--- /dev/null
+++ b/packages/adapters/codex-local/src/server/device-login-runner.ts
@@ -0,0 +1,206 @@
+import { parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js";
+
+// The device-login runner. It runs the Codex device-login command through an
+// injected {@link SandboxLoginDriver}, surfaces the login prompt one time in
+// memory, and handles a timeout and a cancellation. The runner always disposes
+// the driver.
+//
+// Security (Control 1 — secret handling): the runner treats every byte of the
+// sandbox stream as secret-bearing, untrusted input. It parses the stream in an
+// in-memory buffer only. It drops the buffer as soon as it finds the prompt. It
+// never forwards the raw text to a log or an artifact, and it never stores the
+// raw text on the result. The runner reports only a fixed, non-secret status. It
+// passes the prompt one time through the in-memory `onPrompt` callback. It passes
+// the credential bytes one time through the in-memory `onCredential` callback.
+// The runner keeps the URL, the code, and any token byte out of every log line
+// and every thrown error.
+
+/** The default Codex device-login command. */
+export const CODEX_DEVICE_LOGIN_COMMAND = "codex login --device-auth";
+
+/**
+ * The maximum number of characters the runner keeps for the next chunk. The
+ * Codex prompt is small and puts the URL and the code close together. A sandbox
+ * can stream a large volume of output before the prompt. So after each parse the
+ * runner keeps only the most recent characters up to this limit. The retained
+ * buffer cannot grow without a bound across many chunks. The limit is far larger
+ * than the prompt, so the trailing window never drops a real prompt that spans a
+ * chunk boundary.
+ */
+const MAX_PARSE_BUFFER_CHARS = 64 * 1024;
+
+/**
+ * The sandbox side of the device-login run. The runner never calls Daytona
+ * directly; a caller injects a concrete driver. A production driver binds these
+ * three methods to a non-persisting Daytona exec path, a file read, and a
+ * sandbox delete.
+ */
+export interface SandboxLoginDriver {
+ /**
+ * Runs `command` in the sandbox and streams standard output to `onStdout` in
+ * memory. Resolves with the command exit code when the command ends. A driver
+ * must not persist the raw output to any durable log.
+ */
+ execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>;
+ /** Reads the bytes of one file from the sandbox. */
+ readFile(path: string): Promise;
+ /** Deletes the sandbox and releases its resources. */
+ dispose(): Promise;
+}
+
+/** Receives the parsed prompt one time in memory. The caller displays it. */
+export type DeviceLoginPromptSink = (prompt: DeviceLoginPrompt) => void;
+
+export type DeviceLoginOutcome = "success" | "failure" | "timeout" | "cancelled";
+
+/** The runner result. It never carries a URL, a code, or a token byte. */
+export interface DeviceLoginResult {
+ outcome: DeviceLoginOutcome;
+ exitCode: number | null;
+ promptSurfaced: boolean;
+}
+
+export interface RunDeviceLoginOptions {
+ /** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */
+ command?: string;
+ /** Receives the parsed prompt one time in memory. The caller displays it. */
+ onPrompt: DeviceLoginPromptSink;
+ /**
+ * Receives the sandbox `auth.json` bytes one time in memory on success. The
+ * runner reads the bytes with {@link SandboxLoginDriver.readFile} before it
+ * disposes the driver. Set together with {@link authPath}.
+ */
+ onCredential?: (authBytes: Buffer) => void | Promise;
+ /** The sandbox path of the credential file to read on success. */
+ authPath?: string;
+ /** The host-side timeout in milliseconds. */
+ timeoutMs: number;
+ /** An optional cancellation signal. */
+ signal?: AbortSignal;
+ /** A non-leaking progress sink. It receives only fixed status lines. */
+ log?: (line: string) => void;
+}
+
+type RaceResult =
+ | { kind: "exit"; exitCode: number | null }
+ | { kind: "timeout" }
+ | { kind: "cancelled" };
+
+/**
+ * Races the streaming exec against the timeout and the cancellation signal. The
+ * exec result resolves the race; the timeout and the signal resolve the race
+ * with a terminal status. A driver error rejects the race, so the caller can
+ * convert it to a fixed, non-secret error. A late exec rejection after the race
+ * already settled is consumed here, so it never becomes an unhandled rejection.
+ */
+function raceExec(
+ exec: Promise<{ exitCode: number | null }>,
+ timeoutMs: number,
+ signal: AbortSignal | undefined,
+): Promise {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const cleanup = () => {
+ clearTimeout(timer);
+ if (signal) signal.removeEventListener("abort", onAbort);
+ };
+ const finish = (run: () => void) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ run();
+ };
+ const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs);
+ const onAbort = () => finish(() => resolve({ kind: "cancelled" }));
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
+ exec.then(
+ (value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })),
+ (error) => finish(() => reject(error)),
+ );
+ });
+}
+
+/**
+ * Runs the device-login command through `driver`. Surfaces the prompt one time
+ * through `onPrompt`. On success it reads the credential and surfaces the bytes
+ * one time through `onCredential`. Returns a fixed status. Always disposes the
+ * driver. Never logs the raw stream, and never puts a URL, a code, or a token
+ * into a log line, the result, or a thrown error.
+ */
+export async function runDeviceLogin(
+ driver: SandboxLoginDriver,
+ options: RunDeviceLoginOptions,
+): Promise {
+ const { onPrompt, onCredential, authPath, timeoutMs, signal } = options;
+ const command = options.command ?? CODEX_DEVICE_LOGIN_COMMAND;
+ const log = options.log ?? (() => {});
+
+ let promptSurfaced = false;
+ // The in-memory parse buffer. The runner drops it as soon as it finds the
+ // prompt, so the secret-bearing stream never lives longer than one parse. The
+ // runner parses the full buffer, and it includes the whole new chunk. So a
+ // prompt at the start of one large chunk still parses. The runner bounds only
+ // the buffer that it keeps for the next chunk to {@link MAX_PARSE_BUFFER_CHARS}.
+ // The runner keeps the trailing window and drops the oldest characters. The
+ // prompt puts the URL and the code close together, so the trailing window
+ // always holds a real prompt that spans a chunk boundary.
+ let buffer = "";
+ const onStdout = (chunk: string): void => {
+ if (promptSurfaced) return;
+ buffer += chunk;
+ const prompt = parseDeviceLoginPrompt(buffer);
+ if (prompt) {
+ promptSurfaced = true;
+ buffer = "";
+ onPrompt(prompt);
+ return;
+ }
+ if (buffer.length > MAX_PARSE_BUFFER_CHARS) {
+ buffer = buffer.slice(buffer.length - MAX_PARSE_BUFFER_CHARS);
+ }
+ };
+
+ try {
+ if (signal?.aborted) {
+ log("[paperclip] Device login cancelled before start.");
+ return { outcome: "cancelled", exitCode: null, promptSurfaced };
+ }
+
+ const exec = driver.execStreaming(command, onStdout);
+ const raced = await raceExec(exec, timeoutMs, signal);
+
+ if (raced.kind === "timeout") {
+ log("[paperclip] Device login timed out; disposing the sandbox.");
+ return { outcome: "timeout", exitCode: null, promptSurfaced };
+ }
+ if (raced.kind === "cancelled") {
+ log("[paperclip] Device login cancelled; disposing the sandbox.");
+ return { outcome: "cancelled", exitCode: null, promptSurfaced };
+ }
+
+ const exitCode = raced.exitCode;
+ if (exitCode !== 0) {
+ log("[paperclip] Device login command ended with a non-zero exit code.");
+ return { outcome: "failure", exitCode, promptSurfaced };
+ }
+
+ if (onCredential && authPath) {
+ const authBytes = await driver.readFile(authPath);
+ await onCredential(authBytes);
+ }
+ log("[paperclip] Device login command ended successfully.");
+ return { outcome: "success", exitCode, promptSurfaced };
+ } catch {
+ // Convert any driver error to a fixed, non-secret error. The original error
+ // may embed streamed bytes, so the runner never propagates its message.
+ throw new Error("device login failed: the sandbox login command errored.");
+ } finally {
+ // Always dispose the driver. A dispose error must not leak or mask the
+ // result, so the runner swallows it and logs a fixed line.
+ try {
+ await driver.dispose();
+ } catch {
+ log("[paperclip] Device login: the sandbox dispose step errored.");
+ }
+ }
+}
From 8f7b8b3fdab2c6940f5d712134d9f62e42c7a293 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 16:52:59 -0700
Subject: [PATCH 025/201] feat(release): add human-gated beta channel with
stable soak enforcement (#11008)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
> Follow-up to #11006 (merged): rebased onto master and ready for
review.
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem now publishes canary (every master push),
nightly (scheduled, smoke-gated, added in #11006), and stable (manual)
> - There is still no human-approved release-candidate lane between
nightly and stable, and nothing enforces that a stable actually soaked
anywhere before shipping
> - Betas need a real approval gate, and stables need a soak policy that
is data, not prose
> - This pull request adds the beta channel: a manual promotion of a
chosen nightly behind the `npm-beta` environment gate, re-smoked after
publish, plus a stable preflight that enforces a 3-day beta soak with a
written-justification bypass
> - The benefit is a complete canary → nightly → beta → stable train
where every stable shipped as a beta first, and emergencies leave a
written trace
## Linked Issues or Issue Description
**Subsystem affected**
Release automation: `scripts/release.sh`, `scripts/release-lib.sh`,
`.github/workflows/release.yml`, `.github/workflows/docker.yml`,
`.github/workflows/release-smoke.yml`.
**Problem or motivation**
After #11006 the project has canary and nightly prerelease lanes, but no
release-candidate lane. Stable promotion has no enforced soak: any ref
can ship as stable directly. There is no approval boundary for a
broader-audience prerelease, and no structured way to record why an
emergency release skipped validation.
**Proposed solution**
Add a `beta` channel: a manual dispatch that promotes a chosen nightly's
source commit, publishes behind the `npm-beta` GitHub environment
(required reviewers are the gate), re-smokes the published beta, and
tags `beta/vX`. Enforce in the stable path that the source commit
shipped as a beta at least 3 days earlier (measured from the beta's npm
publish time), with a `skip_soak_justification` input as the recorded
emergency bypass.
**Alternatives considered**
Codifying the soak policy in docs only. Rejected: an unenforced policy
decays; the preflight makes the policy executable while the
justification input keeps the emergency path usable and auditable.
## What Changed
- `scripts/release.sh` + `scripts/release-lib.sh`: `beta` channel —
requires HEAD to carry a `nightly/v*` tag, publishes the package set as
`YYYY.MDD.P-beta.N` under dist-tag `beta`, tags
`beta/vYYYY.MDD.P-beta.N`
- `.github/workflows/release.yml`:
- `channel: beta` dispatch path: `select_beta` resolves the newest (or
an explicit `source_version`) nightly and fails loudly on selection
problems; `publish_beta` runs behind the `npm-beta` environment, pushes
the tag, and dispatches `docker.yml`; `smoke_beta` re-runs the release
smoke suite against the exact published beta version
- stable path: new `preflight_stable` job enforces the 3-day beta soak
from the beta's npm publish time; `skip_soak_justification` bypasses
with the reason echoed into the job summary; dry runs report without
blocking
- `.github/workflows/docker.yml`: `beta/v*` tags publish `:beta` on both
images, with exact version stamping
- `.github/workflows/release-smoke.yml`: `beta` added to the dispatch
choice list
- Docs: `CHANNELS.md` beta entries; `RELEASING.md` beta lane, soak gate,
and failure playbook; `RELEASE-AUTOMATION-SETUP.md` `npm-beta`
environment setup, including the warning to create the environment
before the first beta dispatch (GitHub auto-creates unprotected
environments on first reference)
- Tests: beta version-counting coverage in
`scripts/release-registry-versions.test.mjs`; beta identity and
nightly-tag guard coverage in
`scripts/__tests__/release-dry-run-notes.test.mjs`
## Verification
- `node --test` on the two touched suites: 17 pass, including the 3 new
beta tests
- `bash -n` on both shell scripts and YAML parse of all three workflows
- After merge, in order: create the `npm-beta` environment, dispatch
`channel: beta` with `dry_run: true` to preview, then a real promotion
of a published nightly through the approval gate, then a stable dry-run
against a young beta to see the soak gate report
## Risks
- If the `npm-beta` environment does not exist when the first beta
dispatch runs, GitHub creates it with no protection rules and the beta
publishes without approval. Mitigated by documentation and by creating
the environment before merge (operator step)
- Until the first beta exists, every stable dispatch requires
`skip_soak_justification`. This is deliberate — the first beta ships
immediately after this merges — but it is a behavior change to the
stable dispatch
- The soak clock reads the beta's npm publish time from the registry; a
registry outage makes the preflight fall back to requiring justification
(fail-closed)
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use (repository exploration, local test
execution, live registry and git verification). All code, tests, and
docs in this PR were model-authored under human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.github/workflows/docker.yml | 9 +
.github/workflows/release-smoke.yml | 1 +
.github/workflows/release.yml | 306 +++++++++++++++++-
doc/CHANNELS.md | 26 +-
doc/RELEASE-AUTOMATION-SETUP.md | 26 +-
doc/RELEASING.md | 103 ++++--
.../__tests__/release-dry-run-notes.test.mjs | 32 ++
.../release-verify-workflow.test.mjs | 4 +-
scripts/release-lib.sh | 2 +-
scripts/release-registry-versions.test.mjs | 21 ++
scripts/release.sh | 18 +-
scripts/verify-release-registry-state.mjs | 8 +-
.../verify-release-registry-state.test.mjs | 1 +
13 files changed, 514 insertions(+), 43 deletions(-)
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 42dbbc602bb..2e41dd7657b 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -7,6 +7,7 @@ on:
tags:
- "v*"
- "nightly/v*"
+ - "beta/v*"
# Release workflows push lane tags with GITHUB_TOKEN, and GitHub suppresses
# push-triggered runs for those, so release.yml dispatches this workflow at
# the new tag ref instead. The tag mapping below keys off github.ref either
@@ -52,6 +53,9 @@ jobs:
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
+ refs/tags/beta/v*)
+ version="${GITHUB_REF#refs/tags/beta/v}"
+ ;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
@@ -157,6 +161,7 @@ jobs:
tags: |
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
+ type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
@@ -223,6 +228,9 @@ jobs:
# instead of describing drift from the nearest stable tag.
version="${GITHUB_REF#refs/tags/nightly/v}"
;;
+ refs/tags/beta/v*)
+ version="${GITHUB_REF#refs/tags/beta/v}"
+ ;;
*)
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
;;
@@ -330,6 +338,7 @@ jobs:
tags: |
type=raw,value=canary,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }}
+ type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml
index 073be39d383..5ec66da8a69 100644
--- a/.github/workflows/release-smoke.yml
+++ b/.github/workflows/release-smoke.yml
@@ -11,6 +11,7 @@ on:
options:
- canary
- nightly
+ - beta
- latest
host_port:
description: Host port for the Docker smoke container
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 024244408bf..b24bdc18667 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -15,6 +15,7 @@ on:
type: choice
options:
- stable
+ - beta
- nightly
default: stable
source_ref:
@@ -27,7 +28,11 @@ on:
required: false
type: string
source_version:
- description: (nightly) Explicit canary version to promote, for example 2026.806.0-canary.7. Leave empty to select the newest canary on master.
+ description: For nightly, the explicit canary version to promote (empty selects the newest canary on master). For beta, the explicit nightly version to promote (empty selects the newest nightly on master).
+ required: false
+ type: string
+ skip_soak_justification:
+ description: (stable) Written justification for publishing a stable whose source has not soaked as a beta for 3 days. Leave empty for normal releases.
required: false
type: string
dry_run:
@@ -314,17 +319,306 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"
+ # ----- Beta lane --------------------------------------------------------
+ # Beta is a manual, human-approved promotion of a nightly. Unlike the
+ # scheduled nightly lane, a beta dispatch is explicit operator intent, so
+ # selection problems fail the run loudly instead of skipping quietly. The
+ # publish runs behind the npm-beta environment, whose required reviewers
+ # are the approval gate.
+
+ select_beta:
+ if: github.event_name == 'workflow_dispatch' && inputs.channel == 'beta'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ outputs:
+ sha: ${{ steps.select.outputs.sha }}
+ nightly_version: ${{ steps.select.outputs.nightly_version }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ ref: master
+ fetch-depth: 0
+
+ - name: Select beta candidate
+ id: select
+ env:
+ EXPLICIT_NIGHTLY_VERSION: ${{ inputs.source_version }}
+ run: |
+ set -euo pipefail
+
+ git fetch origin --tags --prune --quiet
+
+ if [ -n "${EXPLICIT_NIGHTLY_VERSION:-}" ]; then
+ tag="nightly/v${EXPLICIT_NIGHTLY_VERSION}"
+ sha="$(git rev-list -n 1 "$tag" 2>/dev/null || true)"
+ if [ -z "$sha" ]; then
+ echo "Error: tag $tag does not exist." >&2
+ exit 1
+ fi
+ else
+ # Newest nightly-tagged commit on master.
+ sha="$(grep -m1 -F \
+ -f <(git for-each-ref 'refs/tags/nightly/v*' --format='%(objectname)') \
+ <(git rev-list origin/master -n 2000) || true)"
+
+ if [ -z "$sha" ]; then
+ echo "Error: no nightly/v* tag found on the last 2000 commits of master. Publish a nightly first, or pass source_version." >&2
+ exit 1
+ fi
+
+ tag="$(git tag --points-at "$sha" | grep '^nightly/v' | sort -V | tail -1)"
+ fi
+
+ nightly_version="${tag#nightly/v}"
+
+ existing_beta="$(git tag --points-at "$sha" | grep '^beta/v' | head -1 || true)"
+ if [ -n "$existing_beta" ]; then
+ echo "Error: candidate $sha (nightly $nightly_version) already shipped as $existing_beta." >&2
+ exit 1
+ fi
+
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
+ echo "nightly_version=$nightly_version" >> "$GITHUB_OUTPUT"
+ {
+ echo "## Beta candidate"
+ echo ""
+ echo "- Source SHA: \`$sha\`"
+ echo "- Source nightly: \`$nightly_version\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ publish_beta:
+ needs: select_beta
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ environment: npm-beta
+ # Serialize beta publishes so two dispatches cannot race to the same next
+ # -beta.N version; release.sh additionally refuses to double-publish a
+ # commit that already carries a beta tag.
+ concurrency:
+ group: release-publish-beta
+ cancel-in-progress: false
+ permissions:
+ contents: write
+ id-token: write
+ actions: write
+ outputs:
+ beta_version: ${{ steps.result.outputs.beta_version }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ needs.select_beta.outputs.sha }}
+ fetch-depth: 0
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v6
+ with:
+ version: 9.15.4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v7
+ with:
+ node-version: 24
+ cache: pnpm
+
+ - name: Validate release package manifest
+ run: node ./scripts/release-package-map.mjs check
+
+ - name: Install dependencies
+ run: pnpm install --no-frozen-lockfile
+
+ - name: Restore tracked install-time changes
+ run: git checkout -- pnpm-lock.yaml
+
+ - name: Configure git author
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+
+ - name: Publish beta
+ env:
+ GITHUB_ACTIONS: "true"
+ run: |
+ args=(beta --skip-verify)
+ if [ "${{ inputs.dry_run }}" = "true" ]; then
+ args+=(--dry-run)
+ fi
+ ./scripts/release.sh "${args[@]}"
+
+ - name: Dump npm debug logs
+ if: failure()
+ run: |
+ shopt -s nullglob
+ for f in "$HOME"/.npm/_logs/*.log; do
+ echo "===== $f ====="
+ tail -n 300 "$f" | sed -E \
+ -e 's#((authorization|_authToken|_auth|node_auth_token|npm_token)"?[[:space:]]*[:=][[:space:]]*"?)(Bearer[[:space:]]+)?[^",[:space:]]+#\1***REDACTED***#Ig'
+ done
+
+ - name: Push beta tag
+ if: ${{ !inputs.dry_run }}
+ run: |
+ tag="$(git tag --points-at HEAD | grep '^beta/v' | head -1)"
+ if [ -z "$tag" ]; then
+ echo "Error: no beta tag points at HEAD after release." >&2
+ exit 1
+ fi
+ git push origin "refs/tags/${tag}"
+
+ # Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
+ # trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
+ # pushes), so dispatch the image build at the new tag explicitly.
+ - name: Build Docker images for the beta tag
+ id: result
+ if: ${{ !inputs.dry_run }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ tag="$(git tag --points-at HEAD | grep '^beta/v' | head -1)"
+ echo "beta_version=${tag#beta/v}" >> "$GITHUB_OUTPUT"
+ {
+ echo "## Beta published"
+ echo ""
+ echo "- Source SHA: \`${{ needs.select_beta.outputs.sha }}\`"
+ echo "- Source nightly: \`${{ needs.select_beta.outputs.nightly_version }}\`"
+ echo "- Published beta: \`${tag#beta/v}\`"
+ echo "- Docker build dispatched at \`${tag}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+ gh workflow run docker.yml --ref "refs/tags/${tag}" --repo "$GITHUB_REPOSITORY"
+
+ # Post-publish verification: run the release smoke suite against the exact
+ # beta version that was just published.
+ smoke_beta:
+ needs: publish_beta
+ if: ${{ !inputs.dry_run }}
+ uses: ./.github/workflows/release-smoke.yml
+ with:
+ paperclip_version: ${{ needs.publish_beta.outputs.beta_version }}
+ artifact_name: beta-release-smoke
+
# ----- Stable lane ------------------------------------------------------
+ # Stable releases promote a soaked beta. The preflight enforces that the
+ # source commit shipped as a beta at least 3 days ago (measured from the
+ # npm publish time of that beta version), unless a written justification
+ # is provided. Dry runs report soak state without blocking.
+ # Resolves source_ref to an immutable commit exactly once; every downstream
+ # stable job consumes that SHA. Otherwise a branch or movable tag that
+ # advances mid-run could be soak-checked at one commit and verified or
+ # published at another.
+ preflight_stable:
+ if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ outputs:
+ sha: ${{ steps.soak.outputs.sha }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+ with:
+ ref: master
+ fetch-depth: 0
+
+ - name: Check beta soak
+ id: soak
+ env:
+ SOURCE_REF: ${{ inputs.source_ref }}
+ JUSTIFICATION: ${{ inputs.skip_soak_justification }}
+ DRY_RUN: ${{ inputs.dry_run }}
+ run: |
+ set -euo pipefail
+
+ git fetch origin --tags --prune --quiet
+
+ sha="$(git rev-parse --verify "${SOURCE_REF}^{commit}" 2>/dev/null || true)"
+ if [ -z "$sha" ]; then
+ git fetch origin "$SOURCE_REF" --quiet || true
+ sha="$(git rev-parse --verify "FETCH_HEAD^{commit}" 2>/dev/null || true)"
+ fi
+ if [ -z "$sha" ]; then
+ echo "Error: could not resolve source_ref '$SOURCE_REF' to a commit." >&2
+ exit 1
+ fi
+
+ echo "sha=$sha" >> "$GITHUB_OUTPUT"
+ {
+ echo "## Stable source pinned"
+ echo ""
+ echo "- source_ref: \`${SOURCE_REF}\` -> \`$sha\`"
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ fail_or_justify() {
+ if [ -n "${JUSTIFICATION:-}" ]; then
+ {
+ echo "## Stable soak gate bypassed"
+ echo ""
+ echo "$1"
+ echo ""
+ echo "Justification: ${JUSTIFICATION}"
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "::warning::Soak gate bypassed: $1"
+ return 0
+ fi
+ if [ "${DRY_RUN}" = "true" ]; then
+ echo "::warning::Soak gate would block a real release: $1"
+ {
+ echo "## Stable soak gate (dry run)"
+ echo ""
+ echo "A real release would be blocked: $1"
+ } >> "$GITHUB_STEP_SUMMARY"
+ return 0
+ fi
+ echo "Error: $1" >&2
+ echo "Pass skip_soak_justification with a written reason to release anyway." >&2
+ exit 1
+ }
+
+ beta_tag="$(git tag --points-at "$sha" | grep '^beta/v' | sort -V | tail -1 || true)"
+ if [ -z "$beta_tag" ]; then
+ fail_or_justify "source commit $sha never shipped as a beta (no beta/v* tag)."
+ exit 0
+ fi
+
+ beta_version="${beta_tag#beta/v}"
+ publish_time="$(npm view "paperclipai@${beta_version}" time --json 2>/dev/null \
+ | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const t=JSON.parse(d);process.stdout.write(typeof t === "string" ? t : (t[process.argv[1]] ?? ""))})' "$beta_version" || true)"
+ if [ -z "$publish_time" ]; then
+ fail_or_justify "could not determine the npm publish time of beta ${beta_version}."
+ exit 0
+ fi
+
+ age_seconds="$(node -e 'process.stdout.write(String(Math.floor((Date.now() - Date.parse(process.argv[1])) / 1000)))' "$publish_time")"
+ min_seconds=$((3 * 24 * 60 * 60))
+ age_days="$(node -e 'process.stdout.write((Number(process.argv[1]) / 86400).toFixed(1))' "$age_seconds")"
+
+ if [ "$age_seconds" -lt "$min_seconds" ]; then
+ fail_or_justify "beta ${beta_version} has only soaked ${age_days} days (minimum is 3)."
+ exit 0
+ fi
+
+ {
+ echo "## Stable soak gate passed"
+ echo ""
+ echo "- Source beta: \`${beta_version}\`"
+ echo "- Soak time: ${age_days} days"
+ } >> "$GITHUB_STEP_SUMMARY"
+
verify_stable:
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable'
+ needs: preflight_stable
uses: ./.github/workflows/release-verify.yml
with:
- ref: ${{ inputs.source_ref }}
+ ref: ${{ needs.preflight_stable.outputs.sha }}
preview_stable:
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && inputs.dry_run
- needs: verify_stable
+ needs: [preflight_stable, verify_stable]
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
@@ -335,7 +629,7 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 0
- ref: ${{ inputs.source_ref }}
+ ref: ${{ needs.preflight_stable.outputs.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
@@ -366,7 +660,7 @@ jobs:
publish_stable:
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run
- needs: verify_stable
+ needs: [preflight_stable, verify_stable]
runs-on: ubuntu-latest
timeout-minutes: 45
environment: npm-stable
@@ -380,7 +674,7 @@ jobs:
uses: actions/checkout@v7
with:
fetch-depth: 0
- ref: ${{ inputs.source_ref }}
+ ref: ${{ needs.preflight_stable.outputs.sha }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
diff --git a/doc/CHANNELS.md b/doc/CHANNELS.md
index 9aadd87ba78..b99ae33a3d8 100644
--- a/doc/CHANNELS.md
+++ b/doc/CHANNELS.md
@@ -7,14 +7,20 @@ install.
| Channel | What it is | Updates | npm | Docker |
| --- | --- | --- | --- | --- |
| `stable` | The recommended release | every week or two | `paperclipai@latest` | `ghcr.io/paperclipai/paperclip:latest` |
-| `beta` | Release candidates soaking before stable | when promoted | *(coming soon)* | *(coming soon)* |
+| `beta` | Release candidates soaking before stable | when promoted | `paperclipai@beta` | `ghcr.io/paperclipai/paperclip:beta` |
| `nightly` | Yesterday's merges, smoke-tested as a unit | once a night | `paperclipai@nightly` | `ghcr.io/paperclipai/paperclip:nightly` |
| `canary` | Every merge to `master`, as it happens | many times a day | `paperclipai@canary` | `ghcr.io/paperclipai/paperclip:canary` |
## Choosing a channel
**stable** is the right choice for almost everyone. It only moves when a
-release has been explicitly vetted and promoted by a maintainer.
+release has been explicitly vetted and promoted by a maintainer, and every
+stable must first soak as a beta for at least 3 days.
+
+**beta** is for people who want the next stable early. A beta is a nightly
+that a maintainer hand-picked and explicitly promoted behind an approval
+gate, and it is re-smoked after publishing. Betas are the release candidates:
+what you run on beta today is what stable becomes a few days later.
**nightly** is for people who want new features quickly but not the churn of
tracking every merge. Once a night, the newest master build that published
@@ -33,6 +39,7 @@ npm / npx:
```bash
npx paperclipai@latest onboard # stable
+npx paperclipai@beta onboard
npx paperclipai@nightly onboard
npx paperclipai@canary onboard
```
@@ -41,6 +48,7 @@ Docker:
```bash
docker pull ghcr.io/paperclipai/paperclip:latest # stable
+docker pull ghcr.io/paperclipai/paperclip:beta
docker pull ghcr.io/paperclipai/paperclip:nightly
docker pull ghcr.io/paperclipai/paperclip:canary
```
@@ -61,17 +69,19 @@ directory before switching down.
The version tells you which channel a build came from:
- `2026.807.0` — stable, published Aug 7 2026
+- `2026.807.0-beta.0` — beta promoted on Aug 7 2026
- `2026.807.0-nightly.0` — nightly cut on Aug 7 2026
- `2026.807.0-canary.4` — the fifth canary for the Aug 7 line
-A nightly republishes the exact source commit of a specific canary; the
-version dates the nightly cut, and the two share a source SHA (visible in the
-release job summary and as git tags on the commit).
+Each promotion republishes the exact source commit of the previous lane's
+build: a nightly shares its source SHA with a canary, and a beta with a
+nightly. The version dates the promotion, and the shared SHA is visible in
+the release job summaries and as git tags on the commit.
One quirk to be aware of: npm's semver ordering compares prerelease names
-alphabetically, so `-canary.N` sorts *below* `-nightly.N` for the same base
-version. This never matters when installing by dist-tag (the recommended way),
-only if you write version ranges by hand.
+alphabetically, so `-beta.N` sorts below `-canary.N`, which sorts below
+`-nightly.N` for the same base version. This never matters when installing by
+dist-tag (the recommended way), only if you write version ranges by hand.
## For maintainers
diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md
index 157bf6526c3..e8131595f8b 100644
--- a/doc/RELEASE-AUTOMATION-SETUP.md
+++ b/doc/RELEASE-AUTOMATION-SETUP.md
@@ -113,9 +113,10 @@ Goal:
## 4. Create GitHub Environments
-Create two environments in the GitHub repository:
+Create three environments in the GitHub repository:
- `npm-canary`
+- `npm-beta`
- `npm-stable`
Path:
@@ -147,6 +148,29 @@ the branch rule is satisfied, and reusing the environment means the nightly
lane required no new environments and no npm trusted-publisher changes
(publishing still happens from `release.yml`, see section 2.2).
+## 5.1. Configure `npm-beta`
+
+Recommended settings for `npm-beta`:
+
+- environment name: `npm-beta`
+- required reviewers: at least one maintainer
+- prevent self-review: enabled when your team size allows it
+- wait timer: none
+- deployment branches and tags:
+ - selected branches only
+ - allow `master`
+
+Reasoning:
+
+- beta promotions are deliberate human decisions; the required reviewer on
+ this environment is the promotion gate
+- create this environment before the first `channel: beta` dispatch. If the
+ workflow runs first, GitHub auto-creates the environment with no
+ protection rules, and that first beta would publish without approval
+
+Like nightly, beta publishing lives in `release.yml`, so no npm
+trusted-publisher changes are needed (see section 2.2).
+
## 6. Configure `npm-stable`
Recommended settings for `npm-stable`:
diff --git a/doc/RELEASING.md b/doc/RELEASING.md
index b623bd428a0..1559601ec73 100644
--- a/doc/RELEASING.md
+++ b/doc/RELEASING.md
@@ -7,9 +7,11 @@ The release model is now commit-driven:
1. Every push to `master` publishes a canary automatically.
2. Once a night, the newest master commit with a green canary publish is
smoke-tested and republished as the nightly.
-3. Stable releases are manually promoted from a chosen tested commit or canary tag.
-4. Stable release notes live in `releases/vYYYY.MDD.P.md`.
-5. Only stable releases get GitHub Releases.
+3. Betas are manual, human-approved promotions of a chosen nightly.
+4. Stable releases promote a beta that has soaked for at least 3 days
+ (bypass requires a written justification).
+5. Stable release notes live in `releases/vYYYY.MDD.P.md`.
+6. Only stable releases get GitHub Releases.
The user-facing guide to the channels is [`CHANNELS.md`](CHANNELS.md).
@@ -20,6 +22,7 @@ Paperclip uses calendar versions that still fit semver syntax:
- stable: `YYYY.MDD.P`
- canary: `YYYY.MDD.P-canary.N`
- nightly: `YYYY.MDD.P-nightly.N`
+- beta: `YYYY.MDD.P-beta.N`
Examples:
@@ -27,10 +30,11 @@ Examples:
- second stable on March 18, 2026: `2026.318.1`
- fourth canary for the `2026.318.1` line: `2026.318.1-canary.3`
- first nightly cut on March 18, 2026: `2026.318.1-nightly.0`
+- first beta promoted on March 18, 2026: `2026.318.1-beta.0`
-A nightly republishes the exact source commit of an existing canary; its
-version dates the nightly cut (the scheduled run's UTC date), not the source
-canary.
+A promotion republishes the exact source commit of the previous lane's build
+(canary → nightly → beta); the version dates the promotion, not the source
+build.
Important constraints:
@@ -51,8 +55,8 @@ Every stable release has four separate surfaces:
A stable release is done only when all four surfaces are handled.
-Canaries and nightlies only cover the first two surfaces plus an internal
-traceability tag.
+Canaries, nightlies, and betas only cover the first two surfaces plus an
+internal traceability tag.
## Core Invariants
@@ -60,13 +64,18 @@ traceability tag.
- nightlies republish a commit that already shipped a canary (the commit must
carry a `canary/v*` tag), and only after the release smoke suite passes
against that exact published canary
-- stables publish from an explicitly chosen source ref
+- betas republish a commit that already shipped a nightly (the commit must
+ carry a `nightly/v*` tag), behind the `npm-beta` approval gate, and the
+ published beta is re-smoked
+- stables publish from an explicitly chosen source ref, which must have
+ shipped as a beta at least 3 days earlier unless a written justification
+ is provided
- tags point at the original source commit, not a generated release commit
- stable notes are always `releases/vYYYY.MDD.P.md`
-- canaries and nightlies never create GitHub Releases
-- canaries and nightlies never require changelog generation
+- canaries, nightlies, and betas never create GitHub Releases
+- canaries, nightlies, and betas never require changelog generation
- Docker `:latest` moves only on stable releases; master builds publish
- `:canary` and nightly builds publish `:nightly`
+ `:canary`, nightly builds `:nightly`, and beta builds `:beta`
## TL;DR
@@ -128,6 +137,31 @@ Users install nightlies with:
npx paperclipai@nightly onboard
```
+### Beta
+
+Betas are manual promotions. Dispatch
+[`release.yml`](../.github/workflows/release.yml) with `channel: beta`.
+
+- leave `source_version` empty to promote the newest nightly, or set it to an
+ exact nightly version such as `2026.807.0-nightly.0`
+- the selection job resolves the nightly's source commit and fails loudly if
+ it does not exist or already shipped as a beta
+- the publish waits for approval in the **`npm-beta` environment** — its
+ required reviewers are the promotion gate
+- the same commit is republished as `YYYY.MDD.P-beta.N` under the npm
+ dist-tag `beta`, tagged `beta/vYYYY.MDD.P-beta.N`, and `docker.yml` is
+ dispatched at that tag to publish the `:beta` images
+- after publishing, the release smoke suite runs against the exact published
+ beta version as verification
+- `dry_run: true` previews the publish and skips the tag push, Docker
+ dispatch, and post-publish smoke
+
+Users install betas with:
+
+```bash
+npx paperclipai@beta onboard
+```
+
### Stable
Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the Actions tab with the manual `workflow_dispatch` inputs.
@@ -137,22 +171,31 @@ Use [`.github/workflows/release.yml`](../.github/workflows/release.yml) from the
Inputs:
- `channel`
- - `stable` (the default) for a stable release; `nightly` forces a nightly
- run (see above)
+ - `stable` (the default) for a stable release; `beta` and `nightly` run
+ those lanes instead (see above)
- `source_ref`
- commit SHA, branch, or tag
- `stable_date`
- optional UTC date override in `YYYY-MM-DD`
- enter a date like `2026-03-18`, not a version like `2026.318.0`
+- `skip_soak_justification`
+ - written reason for releasing a stable whose source has not soaked as a
+ beta for 3 days; leave empty for normal releases
- `dry_run`
- preview only when true
+The stable preflight enforces the beta soak: the source commit must carry a
+`beta/v*` tag whose npm publish time is at least 3 days old. If it is not,
+the run fails unless `skip_soak_justification` is provided; the justification
+is echoed into the job summary. Dry runs report soak state without blocking.
+
Before running stable:
-1. pick the canary commit or tag you trust
-2. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version`
-3. create or update `releases/vYYYY.MDD.P.md` on that source ref
-4. run the stable workflow from that source ref
+1. pick the beta you are promoting (its source commit is the `source_ref`)
+2. confirm the beta has soaked for 3 days with no open blockers
+3. resolve the target stable version with `./scripts/release.sh stable --date "$(date +%F)" --print-version`
+4. create or update `releases/vYYYY.MDD.P.md` on that source ref
+5. run the stable workflow from that source ref
Example:
@@ -179,6 +222,7 @@ image and the `-cloud` variant with the same lane mapping:
| --- | --- |
| `master` push | `:canary`, `:sha-` |
| `nightly/v*` tag | `:nightly`, `:sha-` |
+| `beta/v*` tag | `:beta`, `:sha-` |
| `v*` tag (stable) | `:latest`, `:YYYY.MDD.P`, `:YYYY.MDD`, `:sha-` |
Lane tags are pushed by release workflows using `GITHUB_TOKEN`, and GitHub
@@ -203,6 +247,15 @@ Requires HEAD to be a commit that already shipped a canary (it must carry a
./scripts/release.sh nightly --dry-run
```
+### Preview a beta locally
+
+Requires HEAD to be a commit that already shipped a nightly (it must carry a
+`nightly/v*` tag):
+
+```bash
+./scripts/release.sh beta --dry-run
+```
+
### Preview a stable locally
```bash
@@ -266,11 +319,13 @@ Automated browser smoke is also available:
```bash
gh workflow run release-smoke.yml -f paperclip_version=canary
gh workflow run release-smoke.yml -f paperclip_version=nightly
+gh workflow run release-smoke.yml -f paperclip_version=beta
gh workflow run release-smoke.yml -f paperclip_version=latest
```
The nightly lane runs this same suite automatically against its candidate
-before publishing.
+before publishing, and the beta lane runs it against the published beta as
+post-publish verification.
Minimum checks:
@@ -321,6 +376,16 @@ force one: dispatch `release.yml` with `channel: nightly` (optionally pinning
If the nightly published to npm but the tag push or Docker dispatch failed,
push the `nightly/v*` tag manually and run `docker.yml` at that tag.
+### If a beta looks bad during soak
+
+Do not promote it to stable. Fix forward: land the fix on `master`, let it
+ship through canary and nightly, and promote a new beta. The soak clock
+starts over for the new beta.
+
+If the published beta is actively harmful to beta users, move the `beta`
+dist-tag back to the previous beta version with `npm dist-tag add` per
+package, and re-point the `:beta` Docker tags at the previous beta's images.
+
### If stable npm publish succeeds but tag push or GitHub release creation fails
This is a partial release. npm is already live.
diff --git a/scripts/__tests__/release-dry-run-notes.test.mjs b/scripts/__tests__/release-dry-run-notes.test.mjs
index cbd7f460ad8..64f70fcc0fe 100644
--- a/scripts/__tests__/release-dry-run-notes.test.mjs
+++ b/scripts/__tests__/release-dry-run-notes.test.mjs
@@ -195,3 +195,35 @@ test("nightly refuses commits that already shipped as a nightly", () => {
assert.match(result.output, /HEAD already shipped as nightly\/v/);
assert.doesNotMatch(result.calls, /^pnpm /m);
});
+
+test("beta dry-run publishes under the beta identity without release notes", () => {
+ const result = runRelease(["beta", "--skip-verify", "--dry-run"]);
+
+ assert.equal(result.status, 42);
+ assert.match(result.output, /\[fixture\] require_channel_tag_at_head nightly/);
+ assert.match(result.output, /Beta version: 2026\.710\.0-beta\.0/);
+ assert.match(result.output, /Dist-tag: beta/);
+ assert.match(result.output, /Git tag: beta\/v2026\.710\.0-beta\.0/);
+ assert.doesNotMatch(result.output, /stable release notes file is required/);
+ assert.match(result.calls, /^pnpm build$/m);
+});
+
+test("beta refuses commits that already shipped as a beta", () => {
+ const result = runRelease(["beta", "--skip-verify", "--dry-run"], {
+ FAKE_PRESENT_CHANNEL_TAG: "beta",
+ });
+
+ assert.equal(result.status, 1);
+ assert.match(result.output, /HEAD already shipped as beta\/v/);
+ assert.doesNotMatch(result.calls, /^pnpm /m);
+});
+
+test("beta refuses commits that never shipped a nightly", () => {
+ const result = runRelease(["beta", "--skip-verify", "--dry-run"], {
+ FAKE_MISSING_CHANNEL_TAG: "nightly",
+ });
+
+ assert.equal(result.status, 1);
+ assert.match(result.output, /HEAD has no nightly\/v\* tag/);
+ assert.doesNotMatch(result.calls, /^pnpm /m);
+});
diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs
index d259376d237..29ffad1469f 100644
--- a/scripts/__tests__/release-verify-workflow.test.mjs
+++ b/scripts/__tests__/release-verify-workflow.test.mjs
@@ -20,9 +20,11 @@ test("release workflow delegates stable and canary verification to the reusable
// The stable lane is gated on the stable channel since the nightly lane
// was added; a `needs:` line (for example a preflight job) may sit between
// the gate and the delegation.
+ // The stable preflight resolves source_ref to an immutable SHA exactly
+ // once; verification must consume that pin, not re-resolve the ref.
assert.match(
releaseWorkflow,
- /verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch' && inputs\.channel == 'stable'\n(?:\s+needs: [^\n]+\n)?\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ inputs\.source_ref \}\}/,
+ /verify_stable:\n\s+if: github\.event_name == 'workflow_dispatch' && inputs\.channel == 'stable'\n(?:\s+needs: [^\n]+\n)?\s+uses: \.\/\.github\/workflows\/release-verify\.yml\n\s+with:\n\s+ref: \$\{\{ needs\.preflight_stable\.outputs\.sha \}\}/,
);
assert.doesNotMatch(releaseWorkflow, /verify_(?:canary|stable):[\s\S]*?pnpm test:run(?:\n|$)/);
});
diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh
index 7e3b1b4de98..6d1c37fe522 100644
--- a/scripts/release-lib.sh
+++ b/scripts/release-lib.sh
@@ -199,7 +199,7 @@ NODE
require_prerelease_channel() {
case "$1" in
- canary|nightly) ;;
+ canary|nightly|beta) ;;
*) release_fail "unknown prerelease channel: $1" ;;
esac
}
diff --git a/scripts/release-registry-versions.test.mjs b/scripts/release-registry-versions.test.mjs
index 08566a699fc..ff107d9b0eb 100644
--- a/scripts/release-registry-versions.test.mjs
+++ b/scripts/release-registry-versions.test.mjs
@@ -217,6 +217,27 @@ test("next_prerelease_version counts per channel so nightly numbering ignores ca
assert.doesNotMatch(result.calls, /npm view/);
});
+test("next_prerelease_version counts beta numbering independently of other channels", () => {
+ const fixture = makeFixture();
+ const versionsFile = join(fixture.fixtureDir, "versions.json");
+ writeFileSync(
+ versionsFile,
+ JSON.stringify({
+ "@paperclipai/a": ["2026.707.1-canary.4", "2026.707.1-nightly.3", "2026.707.1-beta.0"],
+ }),
+ );
+
+ const result = runReleaseLibHelper(
+ 'next_prerelease_version beta 2026.707.1 "@paperclipai/a"',
+ fixture,
+ { RELEASE_PACKAGE_VERSIONS_FILE: versionsFile },
+ );
+
+ assert.equal(result.status, 0);
+ assert.equal(result.output, "2026.707.1-beta.1");
+ assert.doesNotMatch(result.calls, /npm view/);
+});
+
test("next_prerelease_version rejects unknown channels", () => {
const fixture = makeFixture();
const result = runReleaseLibHelper(
diff --git a/scripts/release.sh b/scripts/release.sh
index 74e563db7a6..db3a4ea90a4 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -18,12 +18,13 @@ cleanup_on_exit=false
usage() {
cat <<'EOF'
Usage:
- ./scripts/release.sh [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
+ ./scripts/release.sh [--date YYYY-MM-DD] [--dry-run] [--skip-verify] [--print-version]
Examples:
./scripts/release.sh canary
./scripts/release.sh canary --date 2026-03-17 --dry-run
./scripts/release.sh nightly --dry-run
+ ./scripts/release.sh beta --dry-run
./scripts/release.sh stable
./scripts/release.sh stable --date 2026-03-17 --dry-run
./scripts/release.sh stable --date 2026-03-18 --print-version
@@ -37,6 +38,9 @@ Notes:
must carry a canary/v* tag) as YYYY.MDD.P-nightly.N under the npm
dist-tag "nightly", with the git tag nightly/vYYYY.MDD.P-nightly.N.
The version dates the nightly cut, not the source canary.
+ - Beta releases republish a commit that already shipped a nightly (HEAD
+ must carry a nightly/v* tag) as YYYY.MDD.P-beta.N under the npm
+ dist-tag "beta", with the git tag beta/vYYYY.MDD.P-beta.N.
- Stable releases publish YYYY.MDD.P under the npm dist-tag "latest" and
create the git tag vYYYY.MDD.P.
- Non-dry-run stable release notes must already exist at releases/vYYYY.MDD.P.md.
@@ -90,7 +94,7 @@ set_cleanup_trap() {
while [ $# -gt 0 ]; do
case "$1" in
- canary|nightly|stable)
+ canary|nightly|beta|stable)
if [ -n "$channel" ]; then
release_fail "only one release channel may be provided."
fi
@@ -162,6 +166,13 @@ elif [ "$channel" = "nightly" ]; then
TARGET_PUBLISH_VERSION="$(next_prerelease_version nightly "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
DIST_TAG="nightly"
tag_name="$(prerelease_tag_name nightly "$TARGET_PUBLISH_VERSION")"
+elif [ "$channel" = "beta" ]; then
+ # Beta promotes an already-shipped nightly commit.
+ require_channel_tag_at_head nightly
+ require_channel_tag_absent_at_head beta
+ TARGET_PUBLISH_VERSION="$(next_prerelease_version beta "$TARGET_STABLE_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}")"
+ DIST_TAG="beta"
+ tag_name="$(prerelease_tag_name beta "$TARGET_PUBLISH_VERSION")"
else
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
fi
@@ -209,6 +220,7 @@ release_info " Target stable version: $TARGET_STABLE_VERSION"
case "$channel" in
canary) release_info " Canary version: $TARGET_PUBLISH_VERSION" ;;
nightly) release_info " Nightly version: $TARGET_PUBLISH_VERSION" ;;
+ beta) release_info " Beta version: $TARGET_PUBLISH_VERSION" ;;
*) release_info " Stable version: $TARGET_PUBLISH_VERSION" ;;
esac
release_info " Dist-tag: $DIST_TAG"
@@ -372,7 +384,7 @@ if [ "$dry_run" = true ]; then
release_info "Dry run complete for $channel ${TARGET_PUBLISH_VERSION}."
else
case "$channel" in
- canary|nightly)
+ canary|nightly|beta)
release_info "Published $channel ${TARGET_PUBLISH_VERSION}."
release_info "Install with: npx paperclipai@$channel onboard"
release_info "Next step: git push ${PUBLISH_REMOTE} refs/tags/${tag_name}"
diff --git a/scripts/verify-release-registry-state.mjs b/scripts/verify-release-registry-state.mjs
index 6ac110aa974..c4848bafaba 100644
--- a/scripts/verify-release-registry-state.mjs
+++ b/scripts/verify-release-registry-state.mjs
@@ -3,9 +3,9 @@
import { pathToFileURL } from "node:url";
const CANARY_VERSION_RE = /-canary\.\d+$/;
-const PRERELEASE_VERSION_RE = /-(?:canary|nightly)\.\d+$/;
+const PRERELEASE_VERSION_RE = /-(?:canary|nightly|beta)\.\d+$/;
// Channels that publish prerelease versions and must never move `latest`.
-const PRERELEASE_CHANNELS = new Set(["canary", "nightly"]);
+const PRERELEASE_CHANNELS = new Set(["canary", "nightly", "beta"]);
const EXIT_RETRIABLE_FAILURE = 1;
const EXIT_NON_RETRIABLE_FAILURE = 2;
@@ -29,7 +29,7 @@ function usage() {
process.stderr.write(
[
"Usage:",
- " node scripts/verify-release-registry-state.mjs --channel --dist-tag --target-version --package [--package ...] [--allow-canary-latest]",
+ " node scripts/verify-release-registry-state.mjs --channel --dist-tag --target-version --package [--package ...] [--allow-canary-latest]",
"",
].join("\n"),
);
@@ -77,7 +77,7 @@ function parseArgs(argv) {
}
if (!PRERELEASE_CHANNELS.has(options.channel) && options.channel !== "stable") {
- throw createExitError("--channel must be canary, nightly, or stable", EXIT_NON_RETRIABLE_FAILURE);
+ throw createExitError("--channel must be canary, nightly, beta, or stable", EXIT_NON_RETRIABLE_FAILURE);
}
if (!options.distTag) {
diff --git a/scripts/verify-release-registry-state.test.mjs b/scripts/verify-release-registry-state.test.mjs
index ec5906d6765..112d61c307f 100644
--- a/scripts/verify-release-registry-state.test.mjs
+++ b/scripts/verify-release-registry-state.test.mjs
@@ -19,6 +19,7 @@ test("isCanaryVersion matches release canaries", () => {
test("isPrereleaseVersion matches canary and nightly versions", () => {
assert.equal(isPrereleaseVersion("2026.427.0-canary.3"), true);
assert.equal(isPrereleaseVersion("2026.427.0-nightly.0"), true);
+ assert.equal(isPrereleaseVersion("2026.427.0-beta.2"), true);
assert.equal(isPrereleaseVersion("2026.427.0"), false);
});
From d816eb80950384cb95b8bc9980e84bd42c0a2ab9 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 17:00:32 -0700
Subject: [PATCH 026/201] fix(server): keep imported tasks quiescent under the
productivity review sweep (#11191)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Company import brings a full company package — agents, tasks,
routines — into an instance, with `pauseAutomations` promising a quiet
landing
> - The pause covers the imported entities, but the destination's own
productivity-review sweep does not know the difference between imported
rows and live work
> - The importer stamps every imported in-progress task with `startedAt
= now()`, so six hours later the sweep's long-active check fires on
every one of them and floods the board with review tasks and agent
wakeups
> - This pull request stops fabricating `startedAt` on import and makes
the sweep skip tasks whose assignee agent is paused
> - The benefit is that an import lands quietly: no surprise review-task
storm, and paused teams stay paused until the operator activates them
## Linked Issues or Issue Description
**What happened?**
After importing a company package with automations paused, a batch of
"productivity review" tasks appeared roughly six hours later — one for
every imported in-progress task — each with an owner-agent wakeup. The
user described it as jarring and wasteful. Cause: `importIssues`
fabricates `startedAt = now()` for imported in-progress rows, and
`reconcileProductivityReviews` considers any assigned in-progress task
without checking whether the assignee agent is paused, so its
long-active-duration evidence (6 h threshold) trips on the fabricated
timestamp.
**Expected behavior**
An import with paused automations must be quiescent: no destination
sweep should generate work from imported rows until the operator
unpauses the imported team. A paused agent must not accumulate review
tasks it cannot act on.
**Steps to reproduce**
1. Import a company package containing tasks with status `in_progress`
assigned to agents, with "pause automations" enabled.
2. Wait for the productivity-review reconcile (runs at startup and on
the heartbeat scheduler tick) more than six hours after the import.
3. Observe one new review task plus an owner wakeup per imported
in-progress task.
## What Changed
- `importIssues` no longer fabricates `startedAt` for imported
`in_progress` rows; it inserts null (`server/src/services/issues.ts`).
Audited every consumer of `issues.startedAt` — all are null-tolerant,
and normal checkout/status-transition paths set the value when work
really starts.
- `reconcileProductivityReviews` skips candidates whose assignee agent
is `paused`, counting them as skipped
(`server/src/services/productivity-review.ts`). This is a general rule,
not import-specific: a paused agent cannot act on a review.
- Tests: paused-assignee candidate with an old `startedAt` creates no
review, and creates one after unpausing; imported in-progress issue
lands with null `startedAt` (embedded-Postgres import test); the
pre-existing long-active regression test still passes.
## Verification
- `pnpm vitest run
server/src/__tests__/productivity-review-service.test.ts
server/src/__tests__/company-portability-import-batching.test.ts` — 20
passed, 1 pre-existing opt-in benchmark skip.
- `pnpm vitest run server/src/__tests__/company-portability.test.ts` —
78 passed.
- `pnpm --filter @paperclipai/server typecheck` — clean.
## Risks
- Behavior change beyond imports: tasks assigned to paused agents no
longer receive productivity reviews anywhere. This is intended — the
review would target an agent that cannot respond — and reviews resume on
the first reconcile after unpausing.
- Imported in-progress tasks now carry no `startedAt` until real work
starts on the destination. The one sweep that read the fabricated value
is the one this PR quiets; all other consumers fall back safely (audit
in the commit body).
- Low risk otherwise: no schema change, no API shape change.
## Model Used
- Claude Fable 5 (`claude-fable-5`) via Claude Code CLI, with extended
thinking and tool use (multi-agent implementation with independent
verification).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
...ompany-portability-import-batching.test.ts | 52 +++++++++++++++++++
.../productivity-review-service.test.ts | 22 ++++++++
server/src/services/issues.ts | 4 +-
server/src/services/productivity-review.ts | 5 ++
4 files changed, 82 insertions(+), 1 deletion(-)
diff --git a/server/src/__tests__/company-portability-import-batching.test.ts b/server/src/__tests__/company-portability-import-batching.test.ts
index 4c32bfe5cd4..53ed945bc1d 100644
--- a/server/src/__tests__/company-portability-import-batching.test.ts
+++ b/server/src/__tests__/company-portability-import-batching.test.ts
@@ -1,6 +1,8 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
+ agents,
assets,
+ companies,
createDb,
documentRevisions,
documents,
@@ -371,6 +373,56 @@ describeEmbeddedPostgres("company import batches inserts", () => {
expect(mineAfterCreate.filter((issue) => importedIds.has(issue.id))).toEqual([]);
});
+ it("imports in_progress issues with a null startedAt", async () => {
+ const companyId = randomUUID();
+ const agentId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Carried Over Co",
+ issuePrefix: "CAR",
+ requireBoardApprovalForNewAgents: false,
+ });
+ // pauseAutomations imports assignees paused; paused agents keep assignments.
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "Carried Coder",
+ role: "engineer",
+ status: "paused",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ });
+
+ await issueService(db).importIssues(companyId, [{
+ id: randomUUID(),
+ ref: "carried-over",
+ projectId: null,
+ projectWorkspaceId: null,
+ title: "Carried-over work",
+ description: null,
+ assigneeAgentId: agentId,
+ status: "in_progress",
+ priority: "medium",
+ billingCode: null,
+ assigneeAdapterOverrides: null,
+ executionWorkspaceSettings: null,
+ labelIds: [],
+ monitorNotes: null,
+ monitorScheduledBy: null,
+ }]);
+
+ const [imported] = await db
+ .select({ status: issues.status, startedAt: issues.startedAt })
+ .from(issues)
+ .where(eq(issues.companyId, companyId));
+ expect(imported?.status).toBe("in_progress");
+ // A fabricated import-time startedAt made carried-over work look hours
+ // stale to duration-based sweeps (e.g. the productivity review).
+ expect(imported?.startedAt).toBeNull();
+ });
+
it("rolls back the whole work-product batch when a later chunk fails", async () => {
// Seed a real company + issue to hang work products off of.
const bundle = buildSyntheticBundle({ issueCount: 1, commentsPerIssue: 0, documentsPerIssue: 0 });
diff --git a/server/src/__tests__/productivity-review-service.test.ts b/server/src/__tests__/productivity-review-service.test.ts
index 12b3bdd18c3..a45e4502ed7 100644
--- a/server/src/__tests__/productivity-review-service.test.ts
+++ b/server/src/__tests__/productivity-review-service.test.ts
@@ -505,6 +505,28 @@ describeEmbeddedPostgres("productivity review service", () => {
expect(hold.held).toBe(false);
});
+ it("skips a long-active candidate while its assignee is paused and reviews it once unpaused", async () => {
+ const now = new Date("2026-04-28T12:00:00.000Z");
+ const seeded = await seedAssignedIssue({
+ status: "in_progress",
+ startedAt: new Date(now.getTime() - 7 * 60 * 60 * 1000),
+ });
+ await db.update(agents).set({ status: "paused" }).where(eq(agents.id, seeded.coderId));
+ const service = productivityReviewService(db);
+
+ const pausedResult = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
+
+ expect(pausedResult.created).toBe(0);
+ expect(pausedResult.skipped).toBe(1);
+ expect(await listProductivityReviews(seeded.companyId)).toHaveLength(0);
+
+ await db.update(agents).set({ status: "idle" }).where(eq(agents.id, seeded.coderId));
+ const unpausedResult = await service.reconcileProductivityReviews({ now, companyId: seeded.companyId });
+
+ expect(unpausedResult.created).toBe(1);
+ expect(await listProductivityReviews(seeded.companyId)).toHaveLength(1);
+ });
+
it("creates a high-churn review even when every sampled run has a progress comment", async () => {
const now = new Date("2026-04-28T12:00:00.000Z");
const seeded = await seedAssignedIssue();
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index 347168911b7..16dbd66207c 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -7352,7 +7352,9 @@ export function issueService(db: Db) {
responsibleUserId: null,
requestDepth: clampIssueRequestDepth(undefined),
originKind: "manual",
- startedAt: row.status === "in_progress" ? new Date() : null,
+ // Imported in-progress work did not start at import time; fabricating
+ // startedAt here trips duration-based sweeps (e.g. productivity review).
+ startedAt: null,
completedAt: row.status === "done" ? new Date() : null,
cancelledAt: row.status === "cancelled" ? new Date() : null,
monitorNotes: row.monitorNotes ?? null,
diff --git a/server/src/services/productivity-review.ts b/server/src/services/productivity-review.ts
index 199b122421c..909682d6758 100644
--- a/server/src/services/productivity-review.ts
+++ b/server/src/services/productivity-review.ts
@@ -896,6 +896,11 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
result.skipped += 1;
continue;
}
+ // A paused assignee cannot act on a review, so raising one only creates noise.
+ if (sourceAgent.status === "paused") {
+ result.skipped += 1;
+ continue;
+ }
const evidence = await collectEvidence(candidate, sourceAgent, thresholds, now);
if (!evidence) {
result.skipped += 1;
From 4c062a0eb2fceb74fbae4325426a9ed69410ef81 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 17:00:36 -0700
Subject: [PATCH 027/201] fix(ui): stop coercing imported agents to the
destination CEO adapter (#11192)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Company import lets an operator bring a package of agents into an
instance, and each agent declares which adapter runs it (Claude Code,
Codex, and so on)
> - The export and the server importer preserve each agent's adapter
faithfully, but the Import page seeds an adapter override for every
agent with the destination CEO's adapter before the user touches
anything
> - Every imported agent therefore arrives as the CEO's adapter (usually
Claude Code) even when the source package holds a mix, and the picker
shows the coerced value as if it were the source's, so nothing looks
wrong
> - This pull request makes the manifest adapter the default, sends
overrides only for agents the user actually changed, and replaces the
silent coercion with an explicit per-agent fallback warning when the
destination truly lacks the source adapter
> - The benefit is that a mixed Claude/Codex team imports as a mixed
Claude/Codex team, and any real adapter gap is visible instead of silent
## Linked Issues or Issue Description
**What happened?**
A user imported a company package whose agents were a mix of Claude Code
and Codex on the source instance. After the import, every agent was
configured as Claude Code. The import preview showed no sign that
anything had been changed. Cause: the Import page initializes its
adapter-override map by assigning every agent the destination CEO's
adapter type and sends that override for every agent, overriding the
manifest's per-agent adapter server-side. For imports into a new
company, the "CEO adapter" is read from whichever unrelated company is
currently selected.
**Expected behavior**
Imported agents keep the adapter declared in the package. An override is
sent only when the operator explicitly picks a different adapter, or
when the source adapter is not installed on the destination — and in
that case the page must say so per agent, not silently substitute.
**Steps to reproduce**
1. On a source instance, create a company with one Claude Code agent and
one Codex agent, and export it.
2. Import the package on another instance whose CEO uses Claude Code,
changing nothing in the import dialog.
3. Both agents arrive configured as Claude Code; the Codex identity is
gone.
## What Changed
- The preview no longer seeds adapter overrides; the override map starts
empty, and the picker displays each agent's manifest adapter
(`ui/src/pages/CompanyImport.tsx`).
- `buildFinalAdapterOverrides` sends an entry only when the effective
adapter differs from the manifest or the agent's adapter config was
edited — untouched agents flow through with no override.
- The page fetches the destination's installed adapters (existing
`adaptersApi.list()` client). When a manifest adapter is missing or
disabled on the destination, only that agent defaults to the CEO's
adapter, with a visible amber warning naming both adapters. If the
adapters request fails, the page fails open: manifest adapters are kept
and no coercion happens.
- Tests: untouched mixed-adapter import sends no overrides; a
user-changed agent sends exactly one; a missing destination adapter
produces the fallback plus rendered warning for that agent only; an
adapters-endpoint failure produces no coercion.
## Verification
- `npx vitest run ui/src/pages/CompanyImport.test.tsx` — 19 passed (15
pre-existing + 4 new).
- `pnpm --filter ./ui typecheck` (`tsc -b`) — clean.
## Risks
- Behavior change: users who previously relied on the silent conversion
(importing packages that reference adapters they don't have) now get an
explicit per-agent fallback with a warning — same outcome, visible. The
server's hard rejection of unknown adapter types remains the backstop
for API callers.
- UI-only change; no server or schema impact.
## Model Used
- Claude Fable 5 (`claude-fable-5`) via Claude Code CLI, with extended
thinking and tool use (multi-agent implementation with independent
verification).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
ui/src/pages/CompanyImport.test.tsx | 156 ++++++++++++++++++++++++++++
ui/src/pages/CompanyImport.tsx | 79 +++++++++++---
2 files changed, 222 insertions(+), 13 deletions(-)
diff --git a/ui/src/pages/CompanyImport.test.tsx b/ui/src/pages/CompanyImport.test.tsx
index 310f16203c2..b27910aa2b6 100644
--- a/ui/src/pages/CompanyImport.test.tsx
+++ b/ui/src/pages/CompanyImport.test.tsx
@@ -22,6 +22,9 @@ const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
resume: vi.fn(),
}));
+const mockAdaptersApi = vi.hoisted(() => ({
+ list: vi.fn(),
+}));
const mockRoutinesApi = vi.hoisted(() => ({
update: vi.fn(),
}));
@@ -54,6 +57,10 @@ vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
+vi.mock("../api/adapters", () => ({
+ adaptersApi: mockAdaptersApi,
+}));
+
vi.mock("../api/routines", () => ({
routinesApi: mockRoutinesApi,
}));
@@ -156,6 +163,44 @@ function buildPreviewResult(): CompanyPortabilityPreviewResult {
} as unknown as CompanyPortabilityPreviewResult;
}
+/** Preview whose manifest mixes adapters: one claude_local agent and one codex_local agent. */
+function buildMixedAdapterPreviewResult(): CompanyPortabilityPreviewResult {
+ return {
+ include: { company: true, agents: true, projects: true, issues: true },
+ targetCompanyId: null,
+ targetCompanyName: null,
+ collisionStrategy: "rename",
+ selectedAgentSlugs: ["coder", "researcher"],
+ plan: {
+ companyAction: "create",
+ agentPlans: [
+ { slug: "coder", action: "create", plannedName: "Coder", existingAgentId: null, reason: null },
+ { slug: "researcher", action: "create", plannedName: "Researcher", existingAgentId: null, reason: null },
+ ],
+ projectPlans: [],
+ issuePlans: [],
+ },
+ manifest: {
+ agents: [
+ { slug: "coder", name: "Coder", path: "agents/coder/AGENTS.md", adapterType: "claude_local" },
+ { slug: "researcher", name: "Researcher", path: "agents/researcher/AGENTS.md", adapterType: "codex_local" },
+ ],
+ projects: [],
+ issues: [],
+ skills: [],
+ company: null,
+ },
+ files: {
+ ".paperclip.yaml": 'schema: "paperclip/v1"\n',
+ "agents/coder/AGENTS.md": "---\nname: Coder\n---\n\nYou write code.\n",
+ "agents/researcher/AGENTS.md": "---\nname: Researcher\n---\n\nYou research.\n",
+ },
+ envInputs: [],
+ warnings: [],
+ errors: [],
+ } as unknown as CompanyPortabilityPreviewResult;
+}
+
function buildImportResult(): CompanyPortabilityImportResult {
return {
company: { id: "company-2", name: "Imported Test", action: "created" },
@@ -186,6 +231,10 @@ describe("CompanyImport", () => {
document.body.appendChild(container);
mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } });
mockAgentsApi.list.mockResolvedValue([]);
+ mockAdaptersApi.list.mockResolvedValue([
+ { type: "claude_local", disabled: false },
+ { type: "codex_local", disabled: false },
+ ]);
mockAgentsApi.resume.mockResolvedValue({ id: "agent-1", status: "idle" });
mockRoutinesApi.update.mockResolvedValue({ id: "routine-1", status: "active" });
mockCompaniesApi.importPreview.mockResolvedValue(buildPreviewResult());
@@ -775,4 +824,111 @@ describe("CompanyImport", () => {
// The stored entry is cleared once the job settles.
expect(sessionStorage.getItem("paperclip:company-import-job:company-1:acme/starter")).toBeNull();
});
+
+ /** Adapter selects in the picker list, in manifest order (excludes the target/collision selects). */
+ function findAdapterSelects() {
+ return Array.from(container.querySelectorAll("select"))
+ .filter((select) => select.value !== "new" && select.value !== "existing" && select.value !== "rename");
+ }
+
+ async function previewMixedAdapterPackage() {
+ mockCompaniesApi.importPreview.mockResolvedValue(buildMixedAdapterPreviewResult());
+ await renderPage();
+ await enterGithubUrl();
+ await clickButton((text) => text === "Preview import");
+ }
+
+ function lastImportMeta() {
+ const call = mockCompaniesApi.importBundleAsync.mock.calls.at(-1);
+ expect(call).toBeTruthy();
+ return call![0] as { adapterOverrides?: Record };
+ }
+
+ it("keeps manifest adapters and sends no overrides when the user touches nothing", async () => {
+ await previewMixedAdapterPackage();
+
+ // The picker shows each agent's source adapter, not a coerced CEO default.
+ expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "codex_local"]);
+
+ await clickButton((text) => text.startsWith("Import 3 file"));
+ await settle();
+
+ // Untouched agents flow through with no override at all, so the server
+ // applies each agent's manifest adapterType.
+ expect(lastImportMeta().adapterOverrides).toBeUndefined();
+ });
+
+ it("sends an override only for the agent whose adapter the user changed", async () => {
+ await previewMixedAdapterPackage();
+
+ const coderSelect = findAdapterSelects()[0];
+ expect(coderSelect?.value).toBe("claude_local");
+ await act(async () => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!;
+ setter.call(coderSelect!, "codex_local");
+ coderSelect!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ await flushReact();
+
+ await clickButton((text) => text.startsWith("Import 3 file"));
+ await settle();
+
+ expect(lastImportMeta().adapterOverrides).toEqual({
+ coder: { adapterType: "codex_local" },
+ });
+ });
+
+ it("falls back to the CEO adapter with a visible warning when a manifest adapter is not installed", async () => {
+ // The destination has no codex_local adapter; the CEO fallback (an empty
+ // agent list defaults to claude_local) takes over — never silently.
+ mockAdaptersApi.list.mockResolvedValue([{ type: "claude_local", disabled: false }]);
+ await previewMixedAdapterPackage();
+
+ expect(container.textContent).toContain("source adapter codex_local is not installed here");
+ expect(container.textContent).toContain("will use Claude Code");
+ expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "claude_local"]);
+
+ await clickButton((text) => text.startsWith("Import 3 file"));
+ await settle();
+
+ // Only the unavailable agent is overridden; the claude_local agent still
+ // carries no override and keeps its manifest adapter.
+ expect(lastImportMeta().adapterOverrides).toEqual({
+ researcher: { adapterType: "claude_local" },
+ });
+ });
+
+ it("picks an installed adapter as the fallback when the CEO adapter is itself unavailable", async () => {
+ // Neither codex_local nor the CEO fallback (claude_local) is installed;
+ // the fallback must be an adapter that actually exists on the
+ // destination, never an unavailable type the server would reject.
+ mockAdaptersApi.list.mockResolvedValue([{ type: "gemini_local", disabled: false }]);
+ await previewMixedAdapterPackage();
+
+ expect(container.textContent).toContain("source adapter codex_local is not installed here");
+ expect(findAdapterSelects().map((select) => select.value)).toEqual(["gemini_local", "gemini_local"]);
+
+ await clickButton((text) => text.startsWith("Import 3 file"));
+ await settle();
+
+ expect(lastImportMeta().adapterOverrides).toEqual({
+ researcher: { adapterType: "gemini_local" },
+ coder: { adapterType: "gemini_local" },
+ });
+ });
+
+ it("fails open to manifest adapters when the adapter list cannot be read", async () => {
+ // Unknown availability must never coerce: with no adapter list, every
+ // agent keeps its manifest adapter and no fallback warning renders.
+ mockAdaptersApi.list.mockRejectedValue(new ApiError("adapters unavailable", 500, null));
+ await previewMixedAdapterPackage();
+
+ expect(container.textContent).not.toContain("is not installed here");
+ expect(findAdapterSelects().map((select) => select.value)).toEqual(["claude_local", "codex_local"]);
+
+ await clickButton((text) => text.startsWith("Import 3 file"));
+ await settle();
+
+ expect(lastImportMeta().adapterOverrides).toBeUndefined();
+ });
});
diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx
index a227894a68c..966b6863ea5 100644
--- a/ui/src/pages/CompanyImport.tsx
+++ b/ui/src/pages/CompanyImport.tsx
@@ -14,6 +14,7 @@ import { useToastActions } from "../context/ToastContext";
import { authApi } from "../api/auth";
import { ApiError } from "../api/client";
import { companiesApi, type CompanyImportJobAccepted } from "../api/companies";
+import { adaptersApi } from "../api/adapters";
import { agentsApi } from "../api/agents";
import { routinesApi } from "../api/routines";
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
@@ -559,7 +560,15 @@ const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapter
interface AdapterPickerItem {
slug: string;
name: string;
+ /** Adapter type from the package manifest (the source's adapter). */
adapterType: string;
+ /**
+ * Set when the manifest adapter is not installed on the destination: the
+ * adapter type the agent falls back to unless the user picks another one.
+ * Null when the manifest adapter is usable here (or availability is unknown,
+ * which fails open to the manifest adapter).
+ */
+ fallbackAdapterType: string | null;
}
function AdapterPickerList({
@@ -592,7 +601,8 @@ function AdapterPickerList({
{agents.map((agent) => {
- const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType;
+ const selectedType =
+ adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterType;
const isExpanded = expandedSlugs.has(agent.slug);
const vals = configValues[agent.slug] ?? { ...defaultCreateValues, adapterType: selectedType };
@@ -634,6 +644,14 @@ function AdapterPickerList({
configure adapter
+ {agent.fallbackAdapterType && (
+
+
+ source adapter {agent.adapterType} is not installed here — this agent
+ will use {adapterLabels[selectedType] ?? getAdapterLabel(selectedType)}
+
+
+ )}
{isExpanded && (
adaptersApi.list(),
+ staleTime: 5 * 60 * 1000,
+ });
+ // Null while the list is loading or unreadable: availability is unknown, so
+ // fail open and trust the manifest rather than coercing every agent.
+ const availableAdapterTypes = useMemo(() => {
+ if (!installedAdapters) return null;
+ return new Set(installedAdapters.filter((a) => !a.disabled).map((a) => a.type));
+ }, [installedAdapters]);
+
const localZipHelpText =
"Upload a .zip exported directly from Paperclip. Re-zipped archives created by Finder, Explorer, or other zip tools may not import correctly.";
@@ -952,12 +986,10 @@ export function CompanyImport() {
setSkippedSlugs(new Set());
setConfirmedSlugs(new Set());
- // Initialize adapter overrides — default all agents to the CEO's adapter type
- const defaultAdapters: Record = {};
- for (const agent of result.manifest.agents) {
- defaultAdapters[agent.slug] = ceoAdapterType;
- }
- setAdapterOverrides(defaultAdapters);
+ // Adapter overrides start empty: each agent keeps its manifest adapter
+ // unless the user changes it, or the manifest adapter is not installed
+ // here (handled per-agent via a warned fallback, never seeded silently).
+ setAdapterOverrides({});
setAdapterExpandedSlugs(new Set());
setAdapterConfigValues({});
@@ -1346,9 +1378,11 @@ export function CompanyImport() {
function handleAdapterConfigChange(slug: string, patch: Partial) {
resetMutationState();
+ const agent = adapterAgents.find((a) => a.slug === slug);
+ const currentType = agent ? effectiveAdapterType(agent) : adapterOverrides[slug] ?? "claude_local";
setAdapterConfigValues((prev) => ({
...prev,
- [slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: adapterOverrides[slug] ?? "claude_local" }), ...patch },
+ [slug]: { ...(prev[slug] ?? { ...defaultCreateValues, adapterType: currentType }), ...patch },
}));
}
@@ -1392,23 +1426,42 @@ export function CompanyImport() {
}
}
- // Build the list of agents for adapter picking
+ // Build the list of agents for adapter picking. An agent whose manifest
+ // adapter is not installed on the destination gets a warned fallback to the
+ // CEO's adapter; while availability is unknown the manifest adapter stands.
const adapterAgents = useMemo(() => {
if (!importPreview) return [];
return importPreview.manifest.agents.map((a) => ({
slug: a.slug,
name: a.name,
adapterType: a.adapterType,
+ // The fallback must itself be installed: the CEO's adapter when it is,
+ // else any installed adapter, else null so the manifest adapter stands
+ // and the server's unknown-adapter rejection is the backstop.
+ fallbackAdapterType:
+ availableAdapterTypes && !availableAdapterTypes.has(a.adapterType)
+ ? availableAdapterTypes.has(ceoAdapterType)
+ ? ceoAdapterType
+ : [...availableAdapterTypes][0] ?? null
+ : null,
}));
- }, [importPreview]);
+ }, [importPreview, availableAdapterTypes, ceoAdapterType]);
- // Build final adapterOverrides for import request
+ /** The adapter type an imported agent will actually use: an explicit user pick, else the availability fallback, else the manifest adapter. */
+ function effectiveAdapterType(agent: AdapterPickerItem): string {
+ return adapterOverrides[agent.slug] ?? agent.fallbackAdapterType ?? agent.adapterType;
+ }
+
+ // Build final adapterOverrides for import request. Only agents that diverge
+ // from the manifest adapter (a user pick or an availability fallback) or
+ // carry edited adapter config send an override — untouched agents flow
+ // through with none, so the manifest adapter survives the import.
function buildFinalAdapterOverrides(): Record | undefined {
- if (adapterAgents.length === 0) return undefined;
const overrides: Record = {};
for (const agent of adapterAgents) {
- const selectedType = adapterOverrides[agent.slug] ?? agent.adapterType;
+ const selectedType = effectiveAdapterType(agent);
const configVals = adapterConfigValues[agent.slug];
+ if (selectedType === agent.adapterType && !configVals) continue;
const override: CompanyPortabilityAdapterOverride = { adapterType: selectedType };
if (configVals) {
const uiAdapter = getUIAdapter(selectedType);
From 35aaaa0bd03063e0a7a79e8cb43f281f72823a35 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 17:01:33 -0700
Subject: [PATCH 028/201] feat(server): preserve task timestamps and hierarchy
through company import/export (#11193)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Company export/import moves a whole company — agents, tasks,
comments — between instances as a portable bundle
> - The bundle never carried task timestamps or parent links: the export
writes neither, the importer lets database defaults stamp "now", and
sub-tasks arrive flattened
> - Boards sort by recency, so every imported task showing "created just
now" collapses the task list into import order, and the task hierarchy
the user built is gone
> - This pull request adds created/updated/started/completed/cancelled
timestamps and a parent link to the bundle (schema v7), preserves them
end to end on import, and keeps comment imports from clobbering a
preserved updated time
> - The benefit is that an imported company reads like the company the
user left: same recency order, same task tree
## Linked Issues or Issue Description
**What happened?**
After a company import, every task showed as created at import time.
Recency sorting collapsed to import order, and parent/child task nesting
disappeared. The user called out losing "the meaningful task hierarchy
and recency sorting". Cause: the export bundle has no fields for task
timestamps or parent links, the importer lets `defaultNow()` win on
insert, and the comment importer bumps every touched task's `updatedAt`
to now.
**Expected behavior**
An imported company preserves each task's
creation/update/start/completion times and its position in the task
tree, so sorting and nesting on the destination match the source.
**Steps to reproduce**
1. On a source instance, create tasks over several days, including
sub-tasks nested under parents.
2. Export the company and import it into another instance.
3. Every task shows the import moment as its creation/update time and
all tasks are top-level.
## What Changed
- Export writes
`createdAt`/`updatedAt`/`startedAt`/`completedAt`/`cancelledAt` (ISO,
only when set) and `parent: ` into each task's bundle
extension; a parent outside the export selection drops the edge with an
aggregate warning, mirroring the existing blocker-edge warning
(`server/src/services/company-portability.ts`).
- Bundle schema version 6 → 7. All new fields are optional: v5/v6
bundles import unchanged with a version-aware downlevel warning; bundles
newer than the board still fail closed.
- Manifest parsing validates the new timestamps like comment timestamps
(invalid → warn and ignore, never a hard failure); shared types and the
zod validator carry the new optional fields.
- Import resolves parent slugs to pre-generated destination ids, drops
self-references and cycles from tampered bundles with warnings, and
orders rows parents-first because the self-referencing FK is checked per
insert chunk.
- `importIssues` writes the preserved timestamps (falling back to insert
time when absent; `startedAt` stays null unless bundle-carried, per
#11191's semantics) and `parentId`.
- `addImportedComments` no longer blanket-bumps `updatedAt = now()`; it
takes `GREATEST(updated_at, newest imported comment createdAt)`, so a
preserved update time never regresses while unpreserved rows keep the
old behavior.
## Verification
- `pnpm vitest run server/src/__tests__/company-portability.test.ts
server/src/__tests__/company-portability-import-batching.test.ts
server/src/__tests__/productivity-review-service.test.ts` — 102 passed,
1 pre-existing opt-in benchmark skip. Includes: full round-trip with
exact timestamp equality and a 3-deep parent chain against embedded
Postgres; v6 back-compat (defaults + warning); forward-compat rejection
(v8); cycle/self-reference/invalid-timestamp tampered-bundle handling;
comment-bump preserve-awareness in both directions.
- `pnpm --filter @paperclipai/server typecheck` and `pnpm --filter
@paperclipai/shared typecheck` — clean.
## Risks
- **Rollout ordering**: a board on the previous build (max schema v6)
refuses bundles exported by this build (stamped v7) — the existing
newer-than-supported rejection, working as designed. Cross-instance
moves need the importing board upgraded first. Called out here so
operators aren't surprised during the transition window.
- Parent edges from tampered bundles are dropped with warnings rather
than failing the import; blocker relations already behave this way.
- Timestamps are data-only; no destination schema migration.
Stacked on #11191 (its commit is included here) — merge #11191 first;
this PR then shows only the v7 changes.
## Model Used
- Claude Fable 5 (`claude-fable-5`) via Claude Code CLI, with extended
thinking and tool use (multi-agent implementation with independent
verification).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.../shared/src/types/company-portability.ts | 8 +
.../src/validators/company-portability.ts | 6 +
...ompany-portability-import-batching.test.ts | 152 ++++++++++++-
.../src/__tests__/company-portability.test.ts | 209 +++++++++++++++++-
server/src/services/company-portability.ts | 131 ++++++++++-
server/src/services/import-write-types.ts | 12 +
server/src/services/issues.ts | 44 +++-
7 files changed, 543 insertions(+), 19 deletions(-)
diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts
index 3cc0eb4a6f0..a32486b42fe 100644
--- a/packages/shared/src/types/company-portability.ts
+++ b/packages/shared/src/types/company-portability.ts
@@ -200,6 +200,14 @@ export interface CompanyPortabilityIssueManifestEntry {
workProducts?: CompanyPortabilityIssueWorkProductManifestEntry[];
monitor?: CompanyPortabilityIssueMonitorManifestEntry | null;
attachments?: CompanyPortabilityIssueAttachmentManifestEntry[];
+ /** Slug of the parent task when it is part of the same bundle (schemaVersion >= 7). */
+ parentSlug?: string | null;
+ /** Preserved source timestamps as ISO strings (schemaVersion >= 7); absent in older bundles. */
+ createdAt?: string | null;
+ updatedAt?: string | null;
+ startedAt?: string | null;
+ completedAt?: string | null;
+ cancelledAt?: string | null;
metadata: Record | null;
}
diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts
index eb766e8ecf2..dcd1bdc4db5 100644
--- a/packages/shared/src/validators/company-portability.ts
+++ b/packages/shared/src/validators/company-portability.ts
@@ -222,6 +222,12 @@ export const portabilityIssueManifestEntrySchema = z.object({
workProducts: z.array(portabilityIssueWorkProductManifestEntrySchema).default([]),
monitor: portabilityIssueMonitorManifestEntrySchema.nullable().default(null),
attachments: z.array(portabilityIssueAttachmentManifestEntrySchema).default([]),
+ parentSlug: z.string().min(1).nullable().optional(),
+ createdAt: z.string().datetime().nullable().optional(),
+ updatedAt: z.string().datetime().nullable().optional(),
+ startedAt: z.string().datetime().nullable().optional(),
+ completedAt: z.string().datetime().nullable().optional(),
+ cancelledAt: z.string().datetime().nullable().optional(),
metadata: z.record(z.string(), z.unknown()).nullable(),
});
diff --git a/server/src/__tests__/company-portability-import-batching.test.ts b/server/src/__tests__/company-portability-import-batching.test.ts
index 53ed945bc1d..0c7226aa746 100644
--- a/server/src/__tests__/company-portability-import-batching.test.ts
+++ b/server/src/__tests__/company-portability-import-batching.test.ts
@@ -157,7 +157,7 @@ interface SyntheticBundleOptions {
documentsPerIssue: number;
}
-/** Build an inline import bundle with the requested shape, valid at schemaVersion 6. */
+/** Build an inline import bundle with the requested shape, valid at schemaVersion 7. */
function buildSyntheticBundle(options: SyntheticBundleOptions): {
rootPath: string;
files: Record;
@@ -193,7 +193,7 @@ function buildSyntheticBundle(options: SyntheticBundleOptions): {
};
}
- files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 6, tasks });
+ files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 7, tasks });
return { rootPath: "batching-bench", files };
}
@@ -223,7 +223,7 @@ function buildTouchedIssuesBundle(issueCount: number): {
};
}
- files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 6, tasks });
+ files[".paperclip.yaml"] = renderPaperclipYaml({ schemaVersion: 7, tasks });
return { rootPath: "inbox-flood", files };
}
@@ -423,6 +423,152 @@ describeEmbeddedPostgres("company import batches inserts", () => {
expect(imported?.startedAt).toBeNull();
});
+ it("preserves bundle timestamps and parent hierarchy through a real import", async () => {
+ const files: Record = {};
+ files["COMPANY.md"] = ['---', 'schema: "agentcompanies/v1"', 'name: "Timestamp Fidelity Co"', '---', '', 'Synthetic import bundle.', ''].join("\n");
+ const taskFile = (name: string) => ['---', `name: "${name}"`, "kind: task", '---', '', `${name} body.`, ''].join("\n");
+ files["tasks/a-child/TASK.md"] = taskFile("Child");
+ files["tasks/m-grandchild/TASK.md"] = taskFile("Grandchild");
+ files["tasks/z-parent/TASK.md"] = taskFile("Parent");
+ files[".paperclip.yaml"] = renderPaperclipYaml({
+ schemaVersion: 7,
+ tasks: {
+ // The child slug sorts before its parent so the importer's
+ // parents-first row ordering (not manifest order) must satisfy the
+ // parent foreign key.
+ "a-child": {
+ status: "todo",
+ priority: "medium",
+ parent: "z-parent",
+ createdAt: "2026-01-05T00:00:00.000Z",
+ updatedAt: "2026-03-05T00:00:00.000Z",
+ comments: [{
+ body: "Older than the preserved updatedAt.",
+ authorType: "system",
+ createdAt: "2026-02-01T00:00:00.000Z",
+ }],
+ },
+ "m-grandchild": {
+ status: "todo",
+ priority: "medium",
+ parent: "a-child",
+ createdAt: "2026-01-06T00:00:00.000Z",
+ updatedAt: "2026-01-07T00:00:00.000Z",
+ },
+ "z-parent": {
+ status: "done",
+ priority: "medium",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ completedAt: "2026-02-20T00:00:00.000Z",
+ comments: [{
+ body: "Newer than the preserved updatedAt.",
+ authorType: "system",
+ createdAt: "2026-04-01T00:00:00.000Z",
+ }],
+ },
+ },
+ });
+
+ const portability = companyPortabilityService(db);
+ const result = await portability.importBundle(
+ {
+ source: { type: "inline", rootPath: "timestamp-fidelity", files },
+ include: { company: true, agents: false, projects: false, issues: true },
+ target: { mode: "new_company", newCompanyName: "Timestamp Fidelity Co" },
+ collisionStrategy: "rename",
+ },
+ "user-timestamp-fidelity",
+ );
+ expect(result.warnings).toEqual([]);
+
+ const rows = await db
+ .select({
+ id: issues.id,
+ title: issues.title,
+ parentId: issues.parentId,
+ createdAt: issues.createdAt,
+ updatedAt: issues.updatedAt,
+ startedAt: issues.startedAt,
+ completedAt: issues.completedAt,
+ })
+ .from(issues)
+ .where(eq(issues.companyId, result.company.id));
+ const parent = rows.find((row) => row.title === "Parent")!;
+ const child = rows.find((row) => row.title === "Child")!;
+ const grandchild = rows.find((row) => row.title === "Grandchild")!;
+
+ // The parent chain of three lands intact.
+ expect(parent.parentId).toBeNull();
+ expect(child.parentId).toBe(parent.id);
+ expect(grandchild.parentId).toBe(child.id);
+
+ expect(parent.createdAt).toEqual(new Date("2026-01-01T00:00:00.000Z"));
+ expect(parent.completedAt).toEqual(new Date("2026-02-20T00:00:00.000Z"));
+ expect(parent.startedAt).toBeNull();
+ expect(child.createdAt).toEqual(new Date("2026-01-05T00:00:00.000Z"));
+ expect(grandchild.createdAt).toEqual(new Date("2026-01-06T00:00:00.000Z"));
+ expect(grandchild.updatedAt).toEqual(new Date("2026-01-07T00:00:00.000Z"));
+
+ // A comment older than the preserved updatedAt must not regress it...
+ expect(child.updatedAt).toEqual(new Date("2026-03-05T00:00:00.000Z"));
+ // ...while a newer imported comment still bumps recency forward.
+ expect(parent.updatedAt).toEqual(new Date("2026-04-01T00:00:00.000Z"));
+ });
+
+ it("imports a v6 bundle without the timestamp fields using import-time defaults", async () => {
+ // A little slack absorbs clock drift between this process and Postgres.
+ const importStart = Date.now() - 1_000;
+ const files: Record = {};
+ files["COMPANY.md"] = ['---', 'schema: "agentcompanies/v1"', 'name: "Legacy Shape Co"', '---', '', 'Synthetic import bundle.', ''].join("\n");
+ files["tasks/legacy-task/TASK.md"] = ['---', 'name: "Legacy task"', "kind: task", '---', '', 'Legacy body.', ''].join("\n");
+ files[".paperclip.yaml"] = renderPaperclipYaml({
+ schemaVersion: 6,
+ tasks: {
+ "legacy-task": {
+ status: "todo",
+ priority: "medium",
+ comments: [{
+ body: "Predates the import.",
+ authorType: "system",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ }],
+ },
+ },
+ });
+
+ const portability = companyPortabilityService(db);
+ const result = await portability.importBundle(
+ {
+ source: { type: "inline", rootPath: "legacy-shape", files },
+ include: { company: true, agents: false, projects: false, issues: true },
+ target: { mode: "new_company", newCompanyName: "Legacy Shape Co" },
+ collisionStrategy: "rename",
+ },
+ "user-legacy-shape",
+ );
+ expect(result.warnings).toContain(
+ "This package declares schemaVersion 6 and predates task timestamp and parent link transfer; that task data imports only if the bundle carries it.",
+ );
+
+ const [row] = await db
+ .select({
+ parentId: issues.parentId,
+ createdAt: issues.createdAt,
+ updatedAt: issues.updatedAt,
+ startedAt: issues.startedAt,
+ })
+ .from(issues)
+ .where(eq(issues.companyId, result.company.id));
+ expect(row?.parentId).toBeNull();
+ expect(row?.startedAt).toBeNull();
+ // Without preserved timestamps the row keeps the old behavior: created and
+ // updated at import time, and the comment bump does not drag updatedAt
+ // back to the older comment createdAt.
+ expect(row!.createdAt.getTime()).toBeGreaterThanOrEqual(importStart);
+ expect(row!.updatedAt.getTime()).toBeGreaterThanOrEqual(importStart);
+ });
+
it("rolls back the whole work-product batch when a later chunk fails", async () => {
// Seed a real company + issue to hang work products off of.
const bundle = buildSyntheticBundle({ issueCount: 1, commentsPerIssue: 0, documentsPerIssue: 0 });
diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts
index 9631ddd3b7b..494087e7045 100644
--- a/server/src/__tests__/company-portability.test.ts
+++ b/server/src/__tests__/company-portability.test.ts
@@ -3579,8 +3579,8 @@ describe("company portability", () => {
expect(extension).not.toContain("labelIds");
expect(extension).not.toContain("label-a");
// Fresh exports declare the current bundle shape end-to-end.
- expect(extension).toContain("schemaVersion: 6");
- expect(exported.manifest.schemaVersion).toBe(6);
+ expect(extension).toContain("schemaVersion: 7");
+ expect(exported.manifest.schemaVersion).toBe(7);
expect(exported.manifest.labels).toEqual([
{ name: "bug", color: "#ff0000" },
{ name: "urgent", color: "#00ff00" },
@@ -4030,6 +4030,207 @@ describe("company portability", () => {
);
});
+ function mockTaskHierarchyExportSources() {
+ projectSvc.list.mockResolvedValue([]);
+ projectSvc.listWorkspaces.mockResolvedValue([]);
+ const baseIssue = {
+ description: null,
+ projectId: null,
+ projectWorkspaceId: null,
+ assigneeAgentId: null,
+ priority: "medium",
+ labelIds: [],
+ billingCode: null,
+ executionWorkspaceSettings: null,
+ assigneeAdapterOverrides: null,
+ };
+ issueSvc.list.mockResolvedValue([
+ {
+ ...baseIssue,
+ id: "issue-1",
+ identifier: "PAP-1",
+ title: "Alpha task",
+ status: "done",
+ parentId: null,
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-03-01T00:00:00.000Z"),
+ startedAt: new Date("2026-01-02T00:00:00.000Z"),
+ completedAt: new Date("2026-02-01T00:00:00.000Z"),
+ },
+ {
+ ...baseIssue,
+ id: "issue-2",
+ identifier: "PAP-2",
+ title: "Beta task",
+ status: "todo",
+ parentId: "issue-1",
+ createdAt: new Date("2026-01-05T00:00:00.000Z"),
+ updatedAt: new Date("2026-01-06T00:00:00.000Z"),
+ },
+ {
+ ...baseIssue,
+ id: "issue-3",
+ identifier: "PAP-3",
+ title: "Gamma task",
+ status: "todo",
+ parentId: "issue-2",
+ createdAt: new Date("2026-01-07T00:00:00.000Z"),
+ updatedAt: new Date("2026-01-08T00:00:00.000Z"),
+ },
+ {
+ ...baseIssue,
+ id: "issue-4",
+ identifier: "PAP-4",
+ title: "Delta task",
+ status: "todo",
+ parentId: "issue-outside",
+ },
+ ]);
+ }
+
+ it("carries task timestamps and parent links through export and import", async () => {
+ const { db } = fakeImportDb();
+ const portability = companyPortabilityService(db);
+ mockTaskHierarchyExportSources();
+
+ const exported = await portability.exportBundle("company-1", {
+ include: { company: true, agents: false, projects: false, issues: true },
+ });
+
+ const extension = asTextFile(exported.files[".paperclip.yaml"]);
+ expect(extension).toContain("schemaVersion: 7");
+ expect(extension).toContain('parent: "pap-1"');
+ expect(extension).toContain('createdAt: "2026-01-01T00:00:00.000Z"');
+ expect(exported.warnings).toContain(
+ "1 parent relation references a task outside this export and was not included.",
+ );
+
+ const alphaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-1");
+ const betaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-2");
+ const gammaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-3");
+ const deltaEntry = exported.manifest.issues.find((issue) => issue.slug === "pap-4");
+ expect(alphaEntry).toEqual(expect.objectContaining({
+ parentSlug: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-03-01T00:00:00.000Z",
+ startedAt: "2026-01-02T00:00:00.000Z",
+ completedAt: "2026-02-01T00:00:00.000Z",
+ cancelledAt: null,
+ }));
+ expect(betaEntry?.parentSlug).toBe("pap-1");
+ expect(gammaEntry?.parentSlug).toBe("pap-2");
+ // The unexported parent edge is dropped, not carried by raw id.
+ expect(deltaEntry?.parentSlug).toBeNull();
+ expect(extension).not.toContain("issue-outside");
+
+ companySvc.create.mockResolvedValue({ id: "company-imported", name: "Imported" });
+ accessSvc.ensureMembership.mockResolvedValue(undefined);
+ agentSvc.list.mockResolvedValue([]);
+
+ await portability.importBundle({
+ source: { type: "inline", rootPath: exported.rootPath, files: exported.files },
+ include: { company: true, agents: false, projects: false, issues: true },
+ target: { mode: "new_company", newCompanyName: "Imported" },
+ agents: "all",
+ collisionStrategy: "rename",
+ }, "user-1");
+
+ const importedRows = issueSvc.importIssues.mock.calls[0]![1] as Array<{
+ id: string;
+ title: string;
+ parentId: string | null;
+ createdAt: Date | null;
+ updatedAt: Date | null;
+ startedAt: Date | null;
+ completedAt: Date | null;
+ cancelledAt: Date | null;
+ }>;
+ const alphaRow = importedRows.find((row) => row.title === "Alpha task")!;
+ const betaRow = importedRows.find((row) => row.title === "Beta task")!;
+ const gammaRow = importedRows.find((row) => row.title === "Gamma task")!;
+ const deltaRow = importedRows.find((row) => row.title === "Delta task")!;
+
+ // Timestamps survive the round trip as concrete dates.
+ expect(alphaRow.createdAt).toEqual(new Date("2026-01-01T00:00:00.000Z"));
+ expect(alphaRow.updatedAt).toEqual(new Date("2026-03-01T00:00:00.000Z"));
+ expect(alphaRow.startedAt).toEqual(new Date("2026-01-02T00:00:00.000Z"));
+ expect(alphaRow.completedAt).toEqual(new Date("2026-02-01T00:00:00.000Z"));
+ expect(alphaRow.cancelledAt).toBeNull();
+ expect(betaRow.createdAt).toEqual(new Date("2026-01-05T00:00:00.000Z"));
+
+ // The parent chain of three is intact against the pre-generated ids.
+ expect(alphaRow.parentId).toBeNull();
+ expect(betaRow.parentId).toBe(alphaRow.id);
+ expect(gammaRow.parentId).toBe(betaRow.id);
+ expect(deltaRow.parentId).toBeNull();
+
+ // Rows arrive parents-first so chunked inserts satisfy the parent FK.
+ expect(importedRows.indexOf(alphaRow)).toBeLessThan(importedRows.indexOf(betaRow));
+ expect(importedRows.indexOf(betaRow)).toBeLessThan(importedRows.indexOf(gammaRow));
+ });
+
+ it("drops self-referencing and cyclic parent links from a hand-built bundle with warnings", async () => {
+ const { db } = fakeImportDb();
+ const portability = companyPortabilityService(db);
+
+ const taskFile = (name: string) => ["---", `name: "${name}"`, "kind: task", "---", "", `${name} body.`, ""].join("\n");
+ const files = {
+ "COMPANY.md": ["---", 'schema: "agentcompanies/v1"', 'name: "Tampered Import"', "---", ""].join("\n"),
+ "tasks/task-a/TASK.md": taskFile("Task A"),
+ "tasks/task-b/TASK.md": taskFile("Task B"),
+ "tasks/task-c/TASK.md": taskFile("Task C"),
+ ".paperclip.yaml": [
+ 'schema: "paperclip/v1"',
+ "schemaVersion: 7",
+ "tasks:",
+ " task-a:",
+ ' status: "todo"',
+ ' parent: "task-b"',
+ " task-b:",
+ ' status: "todo"',
+ ' parent: "task-a"',
+ " task-c:",
+ ' status: "todo"',
+ ' parent: "task-c"',
+ ' createdAt: "not-a-timestamp"',
+ "",
+ ].join("\n"),
+ };
+
+ companySvc.create.mockResolvedValue({ id: "company-imported", name: "Tampered Import" });
+ accessSvc.ensureMembership.mockResolvedValue(undefined);
+ agentSvc.list.mockResolvedValue([]);
+
+ const result = await portability.importBundle({
+ source: { type: "inline", rootPath: "tampered-package", files },
+ include: { company: true, agents: false, projects: false, issues: true },
+ target: { mode: "new_company", newCompanyName: "Tampered Import" },
+ agents: "all",
+ collisionStrategy: "rename",
+ }, "user-1");
+
+ const importedRows = issueSvc.importIssues.mock.calls[0]![1] as Array<{
+ title: string;
+ parentId: string | null;
+ createdAt: Date | null;
+ }>;
+ const rowA = importedRows.find((row) => row.title === "Task A")!;
+ const rowB = importedRows.find((row) => row.title === "Task B")!;
+ const rowC = importedRows.find((row) => row.title === "Task C")!;
+
+ // One direction of the two-task cycle survives; the closing edge drops.
+ expect([rowA.parentId, rowB.parentId].filter((parentId) => parentId !== null)).toHaveLength(1);
+ expect(rowC.parentId).toBeNull();
+ expect(rowC.createdAt).toBeNull();
+ expect(result.warnings.filter((warning) => warning.includes("would create a parent cycle"))).toHaveLength(2);
+ expect(result.warnings).toContain(
+ "Task task-c parent task-c was skipped because it would create a parent cycle.",
+ );
+ expect(result.warnings).toContain(
+ "Task task-c createdAt was ignored because it is not a valid timestamp.",
+ );
+ });
+
const attachmentBytesByObjectKey: Record = {
"issues/issue-1/notes.bin": "png-bytes",
"issues/issue-1/screenshot.png": "png-bytes",
@@ -4625,7 +4826,7 @@ describe("company portability", () => {
it("imports unstamped v5 packages with an info warning about task data they predate", async () => {
const portability = companyPortabilityService({} as any);
const v5Warning =
- "This package declares schemaVersion 5 and predates label, blocker, document, work product, monitor, attachment, and embedded image transfer; that task data imports only if the bundle carries it.";
+ "This package declares schemaVersion 5 and predates label, blocker, document, work product, monitor, attachment, embedded image, task timestamp, and parent link transfer; that task data imports only if the bundle carries it.";
companySvc.create.mockResolvedValue({ id: "company-imported", name: "Legacy Import" });
accessSvc.ensureMembership.mockResolvedValue(undefined);
@@ -4674,7 +4875,7 @@ describe("company portability", () => {
const portability = companyPortabilityService({} as any);
await expect(portability.importBundle({
- source: { type: "inline", rootPath: "future-package", files: legacyPackageFiles(["schemaVersion: 7"]) },
+ source: { type: "inline", rootPath: "future-package", files: legacyPackageFiles(["schemaVersion: 8"]) },
include: { company: true, agents: false, projects: false, issues: true },
target: { mode: "new_company", newCompanyName: "Future Import" },
agents: "all",
diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts
index cdf4cb56303..c86c2bbbefd 100644
--- a/server/src/services/company-portability.ts
+++ b/server/src/services/company-portability.ts
@@ -161,8 +161,10 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
// The bundle shape this build reads and writes. Bundles began declaring
// their schemaVersion in the .paperclip.yaml extension at 6; undeclared
-// bundles are read as 5, the last unstamped shape.
-const BUNDLE_SCHEMA_VERSION = 6;
+// bundles are read as 5, the last unstamped shape. 7 adds preserved task
+// timestamps and parent links; 5/6 bundles still import, with those fields
+// falling back to import-time defaults.
+const BUNDLE_SCHEMA_VERSION = 7;
const UNSTAMPED_BUNDLE_SCHEMA_VERSION = 5;
const DEFAULT_IMPORTED_LABEL_COLOR = "#6366f1";
// Blob entries are content-addressed by sha256; the store itself is
@@ -899,6 +901,38 @@ function readPortableIssueLabelNames(value: unknown): string[] {
return names;
}
+/**
+ * Read a preserved issue timestamp from the extension, validated the same way
+ * comment createdAt is (Date.parse). Invalid values are ignored with a
+ * warning — never a hard failure — so a hand-edited bundle still imports.
+ */
+function readPortableIssueTimestamp(
+ value: unknown,
+ warnings: string[],
+ sourceLabel: string,
+ fieldLabel: string,
+): string | null {
+ if (value === undefined || value === null) return null;
+ const raw = asString(value);
+ if (raw && !Number.isNaN(Date.parse(raw))) return raw;
+ warnings.push(`${sourceLabel} ${fieldLabel} was ignored because it is not a valid timestamp.`);
+ return null;
+}
+
+/** Render a stored timestamp as a portable ISO string, or omit it when unset/invalid. */
+function toPortableTimestamp(value: Date | string | null | undefined): string | undefined {
+ if (value == null) return undefined;
+ const date = value instanceof Date ? value : new Date(value);
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
+}
+
+/** Convert a manifest timestamp (already validated at parse time) to a Date, or null. */
+function portableManifestDate(value: string | null | undefined): Date | null {
+ if (!value) return null;
+ const date = new Date(value);
+ return Number.isNaN(date.getTime()) ? null : date;
+}
+
function readPortableIssueBlockedBy(value: unknown): string[] {
if (!Array.isArray(value)) return [];
const slugs: string[] = [];
@@ -3353,6 +3387,12 @@ function buildManifestFromPackageFiles(
workProducts: normalizePortableIssueWorkProducts(extension.workProducts),
monitor: normalizePortableIssueMonitor(extension.monitor),
attachments: normalizePortableIssueAttachments(extension.attachments, warnings, `Task ${slug}`),
+ parentSlug: asString(extension.parent),
+ createdAt: readPortableIssueTimestamp(extension.createdAt, warnings, `Task ${slug}`, "createdAt"),
+ updatedAt: readPortableIssueTimestamp(extension.updatedAt, warnings, `Task ${slug}`, "updatedAt"),
+ startedAt: readPortableIssueTimestamp(extension.startedAt, warnings, `Task ${slug}`, "startedAt"),
+ completedAt: readPortableIssueTimestamp(extension.completedAt, warnings, `Task ${slug}`, "completedAt"),
+ cancelledAt: readPortableIssueTimestamp(extension.cancelledAt, warnings, `Task ${slug}`, "cancelledAt"),
metadata: isPlainRecord(extension.metadata) ? extension.metadata : null,
});
if (frontmatter.kind && frontmatter.kind !== "task") {
@@ -3361,7 +3401,10 @@ function buildManifestFromPackageFiles(
}
if (bundleSchemaVersion < BUNDLE_SCHEMA_VERSION && manifest.issues.length > 0) {
- warnings.push(`This package declares schemaVersion ${bundleSchemaVersion} and predates label, blocker, document, work product, monitor, attachment, and embedded image transfer; that task data imports only if the bundle carries it.`);
+ const predated = bundleSchemaVersion < 6
+ ? "label, blocker, document, work product, monitor, attachment, embedded image, task timestamp, and parent link transfer"
+ : "task timestamp and parent link transfer";
+ warnings.push(`This package declares schemaVersion ${bundleSchemaVersion} and predates ${predated}; that task data imports only if the bundle carries it.`);
}
manifest.envInputs = dedupeEnvInputs(manifest.envInputs);
@@ -4216,6 +4259,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
}
let unexportedBlockerEdgeCount = 0;
+ let unexportedParentEdgeCount = 0;
let unportableWorkProductRefCount = 0;
const exportedBlobs = new Map();
for (const issue of selectedIssueRows) {
@@ -4256,6 +4300,13 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
unexportedBlockerEdgeCount += relationSummaries.blocks
.filter((blocked) => !taskSlugByIssueId.has(blocked.id))
.length;
+ // Parent links travel by task slug like blockers; a parent outside the
+ // export selection drops the edge and is counted for one warning.
+ let parentTaskSlug: string | null = null;
+ if (issue.parentId) {
+ parentTaskSlug = taskSlugByIssueId.get(issue.parentId) ?? null;
+ if (!parentTaskSlug) unexportedParentEdgeCount += 1;
+ }
const issueDocumentRows = await documentsSvc.listIssueDocuments(issue.id, { includeSystem: true });
const documentEntries = issueDocumentRows.map((document) => {
const documentPath = `tasks/${taskSlug}/documents/${document.key}.md`;
@@ -4339,6 +4390,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
identifier: issue.identifier,
status: issue.status,
priority: issue.priority,
+ parent: parentTaskSlug ?? undefined,
+ createdAt: toPortableTimestamp(issue.createdAt),
+ updatedAt: toPortableTimestamp(issue.updatedAt),
+ startedAt: toPortableTimestamp(issue.startedAt),
+ completedAt: toPortableTimestamp(issue.completedAt),
+ cancelledAt: toPortableTimestamp(issue.cancelledAt),
// Labels travel by name (their natural key); the bundle-level labels
// section carries the matching color definitions.
labels: (issue.labelIds ?? [])
@@ -4380,6 +4437,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if (unexportedBlockerEdgeCount > 0) {
warnings.push(`${unexportedBlockerEdgeCount} blocker relation${unexportedBlockerEdgeCount === 1 ? " references a task" : "s reference tasks"} outside this export and ${unexportedBlockerEdgeCount === 1 ? "was" : "were"} not included.`);
}
+ if (unexportedParentEdgeCount > 0) {
+ warnings.push(`${unexportedParentEdgeCount} parent relation${unexportedParentEdgeCount === 1 ? " references a task" : "s reference tasks"} outside this export and ${unexportedParentEdgeCount === 1 ? "was" : "were"} not included.`);
+ }
if (unportableWorkProductRefCount > 0) {
warnings.push(`${unportableWorkProductRefCount} work product${unportableWorkProductRefCount === 1 ? " references" : "s reference"} execution workspaces or runs that are not portable; those references were omitted from the export.`);
}
@@ -5721,6 +5781,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const importedIssueIdBySlug = new Map();
const blockedByBySlug = new Map();
+ const parentSlugBySlug = new Map();
let unarmedMonitorCount = 0;
let attachmentsSkippedNoStorage = 0;
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(targetCompany.attachmentMaxBytes ?? null);
@@ -5924,6 +5985,9 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
if ((manifestIssue.blockedBy ?? []).length > 0) {
blockedByBySlug.set(manifestIssue.slug, manifestIssue.blockedBy ?? []);
}
+ if (manifestIssue.parentSlug) {
+ parentSlugBySlug.set(manifestIssue.slug, manifestIssue.parentSlug);
+ }
for (const documentEntry of manifestIssue.documents ?? []) {
const documentBody = readPortableTextFile(plan.source.files, documentEntry.path);
if (documentBody === null) {
@@ -6050,9 +6114,70 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
labelIds: resolvedLabelIds,
monitorNotes,
monitorScheduledBy,
+ parentId: null,
+ createdAt: portableManifestDate(manifestIssue.createdAt),
+ updatedAt: portableManifestDate(manifestIssue.updatedAt),
+ startedAt: portableManifestDate(manifestIssue.startedAt),
+ completedAt: portableManifestDate(manifestIssue.completedAt),
+ cancelledAt: portableManifestDate(manifestIssue.cancelledAt),
});
}
+ // Parent links resolve against the pre-generated ids before the flush
+ // so each issue row carries its parentId on insert. Edges whose parent
+ // was not imported, self-references, and cycles (possible only in a
+ // hand-edited bundle) drop with a warning, mirroring blocker handling.
+ if (parentSlugBySlug.size > 0) {
+ const rowBySlug = new Map(issueRows.map((row) => [row.ref, row] as const));
+ const acceptedParentById = new Map();
+ const wouldCreateParentCycle = (childId: string, parentId: string) => {
+ let current: string | undefined = parentId;
+ const visited = new Set();
+ while (current) {
+ if (current === childId) return true;
+ if (visited.has(current)) return false;
+ visited.add(current);
+ current = acceptedParentById.get(current);
+ }
+ return false;
+ };
+ for (const [slug, parentSlug] of parentSlugBySlug) {
+ const row = rowBySlug.get(slug);
+ if (!row) continue;
+ const parentId = importedIssueIdBySlug.get(parentSlug);
+ if (!parentId) {
+ warnings.push(`Task ${slug} parent ${parentSlug} was skipped because that task was not imported.`);
+ continue;
+ }
+ if (parentId === row.id || wouldCreateParentCycle(row.id, parentId)) {
+ warnings.push(`Task ${slug} parent ${parentSlug} was skipped because it would create a parent cycle.`);
+ continue;
+ }
+ acceptedParentById.set(row.id, parentId);
+ row.parentId = parentId;
+ }
+ // The parent foreign key is checked per insert statement, so order
+ // rows parents-first: a child must never land in an earlier chunk
+ // than its parent.
+ if (acceptedParentById.size > 0) {
+ const rowById = new Map(issueRows.map((row) => [row.id, row] as const));
+ const orderedRows: typeof issueRows = [];
+ const emitted = new Set();
+ for (const row of issueRows) {
+ const chain: typeof issueRows = [];
+ let current: (typeof issueRows)[number] | undefined = row;
+ while (current && !emitted.has(current.id)) {
+ emitted.add(current.id);
+ chain.push(current);
+ current = current.parentId ? rowById.get(current.parentId) : undefined;
+ }
+ for (const entry of chain.reverse()) orderedRows.push(entry);
+ }
+ issueRows.length = 0;
+ issueRows.push(...orderedRows);
+ }
+ }
+
// Flush the buffered rows in dependency order: issues first (parents of
// every other row), then comments (attachments may reference them), then
// the remaining children. Each writer inserts in chunked multi-row
diff --git a/server/src/services/import-write-types.ts b/server/src/services/import-write-types.ts
index 264f80f97ad..f5d57449849 100644
--- a/server/src/services/import-write-types.ts
+++ b/server/src/services/import-write-types.ts
@@ -35,6 +35,18 @@ export interface ImportIssueRow {
/** Imported monitors land un-armed; only notes/provenance are restored. */
monitorNotes: string | null;
monitorScheduledBy: string | null;
+ /** Parent issue id resolved within the same import batch (pre-generated), or absent for roots. */
+ parentId?: string | null;
+ /**
+ * Preserved source timestamps carried by schemaVersion >= 7 bundles.
+ * Absent/null createdAt and updatedAt fall back to the insert time;
+ * absent startedAt stays null — the writer never fabricates it.
+ */
+ createdAt?: Date | null;
+ updatedAt?: Date | null;
+ startedAt?: Date | null;
+ completedAt?: Date | null;
+ cancelledAt?: Date | null;
}
/** A resolved comment row with a pre-generated id, ready for batch insert. */
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index 16dbd66207c..b87156d913e 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -94,7 +94,7 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
import { getRunLogStore } from "./run-log-store.js";
import { getDefaultCompanyGoal } from "./goals.js";
import { assertAssignableAgent } from "./agent-assignability.js";
-import { insertRowsInChunks } from "./batch-insert.js";
+import { DEFAULT_INSERT_CHUNK_ROWS, insertRowsInChunks } from "./batch-insert.js";
import type {
ImportIssueRow,
ImportIssueCommentRow,
@@ -7352,11 +7352,19 @@ export function issueService(db: Db) {
responsibleUserId: null,
requestDepth: clampIssueRequestDepth(undefined),
originKind: "manual",
+ // The caller resolves parentId against ids in this same batch, so a
+ // parent always lands in the same insert (rows arrive parents-first).
+ parentId: row.parentId ?? null,
+ // Preserved bundle timestamps win; without them createdAt/updatedAt
+ // fall back to the insert time (the old defaultNow() behavior).
+ createdAt: row.createdAt ?? new Date(),
+ updatedAt: row.updatedAt ?? new Date(),
// Imported in-progress work did not start at import time; fabricating
- // startedAt here trips duration-based sweeps (e.g. productivity review).
- startedAt: null,
- completedAt: row.status === "done" ? new Date() : null,
- cancelledAt: row.status === "cancelled" ? new Date() : null,
+ // startedAt here trips duration-based sweeps (e.g. productivity
+ // review). Only a bundle-carried startedAt is written.
+ startedAt: row.startedAt ?? null,
+ completedAt: row.completedAt ?? (row.status === "done" ? new Date() : null),
+ cancelledAt: row.cancelledAt ?? (row.status === "cancelled" ? new Date() : null),
monitorNotes: row.monitorNotes ?? null,
monitorScheduledBy: row.monitorScheduledBy ?? null,
});
@@ -7396,10 +7404,28 @@ export function issueService(db: Db) {
};
});
await insertRowsInChunks(tx, issueComments, commentRows);
- // Mirror addComment's recency bump, once per affected issue.
- const issueIds = [...new Set(rows.map((row) => row.issueId))];
- if (issueIds.length > 0) {
- await tx.update(issues).set({ updatedAt: new Date() }).where(inArray(issues.id, issueIds));
+ // Mirror addComment's recency bump, once per affected issue — but never
+ // backwards. An issue imported with a preserved updatedAt keeps it
+ // unless a newer imported comment outdates it; issues without preserved
+ // timestamps carry an insert-time updatedAt, so GREATEST reproduces the
+ // old "bump to now" behavior for them.
+ const bumpAtByIssueId = new Map();
+ for (const row of commentRows) {
+ const existing = bumpAtByIssueId.get(row.issueId);
+ if (!existing || row.createdAt > existing) bumpAtByIssueId.set(row.issueId, row.createdAt);
+ }
+ const bumpEntries = [...bumpAtByIssueId.entries()];
+ for (let start = 0; start < bumpEntries.length; start += DEFAULT_INSERT_CHUNK_ROWS) {
+ const chunk = bumpEntries.slice(start, start + DEFAULT_INSERT_CHUNK_ROWS);
+ await tx.execute(sql`
+ update ${issues}
+ set updated_at = greatest(${issues.updatedAt}, bumps.bump_at)
+ from (values ${sql.join(
+ chunk.map(([issueId, bumpAt]) => sql`(${issueId}::uuid, ${bumpAt.toISOString()}::timestamptz)`),
+ sql`, `,
+ )}) as bumps(issue_id, bump_at)
+ where ${issues.id} = bumps.issue_id
+ `);
}
});
},
From 6601014898d4d57dae391d643a1f75efbce85035 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 17:22:54 -0700
Subject: [PATCH 029/201] fix(release): reject promotion sources that predate
their channel tooling (#11197)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem promotes builds along canary → nightly → beta
→ stable, and each publish job checks out the promotion's source commit
and runs that tree's release tooling
> - The first beta dispatch failed with `unexpected argument: beta`: the
selected nightly's source predated the beta channel, so its `release.sh`
did not know the argument
> - The failure was clean (argument parsing, nothing published) but
cryptic, and the same trap waits for any promotion of a source older
than its target channel's tooling
> - This pull request makes the selection jobs reject such sources with
an actionable error and documents the property
> - The benefit is that a bootstrapping or old-source promotion fails in
seconds with instructions, instead of mid-publish with a parser error
## Linked Issues or Issue Description
Refs #11008 — the guard hardens the beta promotion flow introduced
there, after its first dispatch surfaced the gap described below.
**Subsystem affected**
Release automation: `.github/workflows/release.yml`, `doc/RELEASING.md`,
workflow wiring tests.
**Problem or motivation**
Run 31444045044 (first beta dispatch) failed in `publish_beta` with
`unexpected argument: beta`. Promotions deliberately build from the
pinned source commit, which means they also run that commit's
`scripts/release.sh` — and a source that predates the target channel's
introduction cannot publish it. Nothing guards this today; the error
surfaces deep in the publish job with no explanation.
**Proposed solution**
Guard at selection time: `select_nightly` requires the source canary's
`release.sh` to know the nightly channel, and `select_beta` requires the
source nightly's `release.sh` to know the beta channel. Each guard
literally matches the channel case arm and fails closed with a clear
message naming the remedy (promote a newer source). Document the
tooling-era property in `RELEASING.md` and pin the guards with a wiring
test.
## What Changed
- `.github/workflows/release.yml`: tooling-era guards in
`select_nightly` and `select_beta`
- `doc/RELEASING.md`: documents that promotions run the source commit's
release tooling
- `scripts/__tests__/release-verify-workflow.test.mjs`: wiring test
pinning both guards
## Verification
- Wiring tests: 5 pass
- Guard expressions exercised against real commits: accepts the
beta-capable merge commit of the beta-channel change, rejects a pre-beta
commit
- YAML parse of the workflow
- After merge: the next beta dispatch selects a beta-capable nightly and
passes the guard
## Risks
- Low. Selection-time check only; the guards match the channel case arm
literally and fail closed (with the same actionable message) if that
line is ever reformatted
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use. All changes model-authored under
human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.github/workflows/release.yml | 18 ++++++++++++++++++
doc/RELEASING.md | 4 ++++
.../__tests__/release-verify-workflow.test.mjs | 9 +++++++++
3 files changed, 31 insertions(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b24bdc18667..c8e74ea146f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -192,6 +192,15 @@ jobs:
exit 0
fi
+ # Promotions run the release tooling of the source commit, so the
+ # source must already understand the nightly channel. (Literal match
+ # of release.sh's channel case arm; if that line is reformatted this
+ # fails closed and should be updated alongside it.)
+ if ! git show "${sha}:scripts/release.sh" | grep -qF 'canary|nightly'; then
+ echo "Error: source commit $sha predates nightly release tooling; promote a newer canary." >&2
+ exit 1
+ fi
+
echo "proceed=true" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "canary_version=$canary_version" >> "$GITHUB_OUTPUT"
@@ -380,6 +389,15 @@ jobs:
exit 1
fi
+ # Promotions run the release tooling of the source commit, so the
+ # source must already understand the beta channel. (Literal match of
+ # release.sh's channel case arm; if that line is reformatted this
+ # fails closed and should be updated alongside it.)
+ if ! git show "${sha}:scripts/release.sh" | grep -qF 'canary|nightly|beta|stable)'; then
+ echo "Error: source commit $sha predates beta release tooling; promote a newer nightly whose source contains the beta channel." >&2
+ exit 1
+ fi
+
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "nightly_version=$nightly_version" >> "$GITHUB_OUTPUT"
{
diff --git a/doc/RELEASING.md b/doc/RELEASING.md
index 1559601ec73..a77ca18729f 100644
--- a/doc/RELEASING.md
+++ b/doc/RELEASING.md
@@ -148,6 +148,10 @@ Betas are manual promotions. Dispatch
it does not exist or already shipped as a beta
- the publish waits for approval in the **`npm-beta` environment** — its
required reviewers are the promotion gate
+- promotions run the release tooling of the source commit, so the source
+ nightly must postdate the beta channel's introduction; the selection job
+ rejects older sources with a clear error (in practice every nightly cut
+ after the beta tooling merged qualifies)
- the same commit is republished as `YYYY.MDD.P-beta.N` under the npm
dist-tag `beta`, tagged `beta/vYYYY.MDD.P-beta.N`, and `docker.yml` is
dispatched at that tag to publish the `:beta` images
diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs
index 29ffad1469f..3e33d0b254e 100644
--- a/scripts/__tests__/release-verify-workflow.test.mjs
+++ b/scripts/__tests__/release-verify-workflow.test.mjs
@@ -38,6 +38,15 @@ test("onboard smoke container binds beyond loopback so the mapped port is reacha
assert.match(dockerfile, /onboard --yes --bind lan/);
});
+test("promotion selection guards against sources that predate their channel tooling", () => {
+ const releaseWorkflow = readWorkflow("release.yml");
+
+ // Promotions run the source commit's release.sh, so selection must reject
+ // sources whose tooling does not know the target channel yet.
+ assert.match(releaseWorkflow, /git show "\$\{sha\}:scripts\/release\.sh" \| grep -qF 'canary\|nightly'/);
+ assert.match(releaseWorkflow, /git show "\$\{sha\}:scripts\/release\.sh" \| grep -qF 'canary\|nightly\|beta\|stable\)'/);
+});
+
test("release smoke workflow extends the container readiness budget for CI", () => {
const smokeWorkflow = readWorkflow("release-smoke.yml");
const harness = readFileSync(path.join(repoRoot, "scripts/docker-onboard-smoke.sh"), "utf8");
From d648becb90a346818e83d42c4439e2456228c657 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Mon, 10 Aug 2026 17:32:44 -0700
Subject: [PATCH 030/201] refactor(ci): split workspaces-a into two Vitest
native shards
Split the slow workspaces-a CI lane into two Vitest native shards and keep release verification in parity.
Co-Authored-By: Paperclip
---
.github/workflows/pr.yml | 14 +++++++-
.github/workflows/release-verify.yml | 10 +++++-
.../release-verify-workflow.test.mjs | 9 +++++
.../run-vitest-stable-shard.test.mjs | 34 +++++++++++++++++--
scripts/run-vitest-stable.mjs | 34 ++++++++++++++++---
5 files changed, 91 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 097692cdecb..925c520ba45 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -174,8 +174,20 @@ jobs:
group_label: server (5/5)
shard_index: 4
shard_count: 5
+ # workspaces-a was the slowest check in the fully-green PR run
+ # 31371439296 (2026-08-10) at 319s, with the ui project's single
+ # vitest invocation accounting for ~224s and the paperclipai CLI
+ # ~37s. Two shards use Vitest's native --shard on each project's
+ # file list (ui: 439 files, cli: 54), bringing each job to roughly
+ # half the suite time (~130s + setup) without a duration manifest.
- group: general-workspaces-a
- group_label: workspaces-a
+ group_label: workspaces-a (1/2)
+ shard_index: 0
+ shard_count: 2
+ - group: general-workspaces-a
+ group_label: workspaces-a (2/2)
+ shard_index: 1
+ shard_count: 2
- group: general-workspaces-b
group_label: workspaces-b
diff --git a/.github/workflows/release-verify.yml b/.github/workflows/release-verify.yml
index cac52d75b5d..df146e7faac 100644
--- a/.github/workflows/release-verify.yml
+++ b/.github/workflows/release-verify.yml
@@ -64,8 +64,16 @@ jobs:
group_label: server (3/3)
shard_index: 2
shard_count: 3
+ # Keep parity with pr.yml: workspaces-a is split with Vitest's
+ # native --shard because the ui project dominates the lane.
- group: general-workspaces-a
- group_label: workspaces-a
+ group_label: workspaces-a (1/2)
+ shard_index: 0
+ shard_count: 2
+ - group: general-workspaces-a
+ group_label: workspaces-a (2/2)
+ shard_index: 1
+ shard_count: 2
- group: general-workspaces-b
group_label: workspaces-b
diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs
index 3e33d0b254e..02de233cb67 100644
--- a/scripts/__tests__/release-verify-workflow.test.mjs
+++ b/scripts/__tests__/release-verify-workflow.test.mjs
@@ -84,6 +84,15 @@ test("release verify workflow covers the same split test surface as stable PR ve
assert.match(verifyWorkflow, new RegExp(`shard_index: ${shardIndex}[\\s\\S]*?shard_count: 5`));
}
+ // workspaces-a splits with Vitest native --shard in pr.yml; release
+ // verification must keep the same two-shard coverage.
+ for (const shardIndex of [0, 1]) {
+ assert.match(
+ verifyWorkflow,
+ new RegExp(`group: general-workspaces-a[\\s\\S]*?shard_index: ${shardIndex}\\n\\s+shard_count: 2`),
+ );
+ }
+
assert.match(verifyWorkflow, /pnpm test:run:general -- --group/);
assert.match(verifyWorkflow, /pnpm test:run:serialized -- --shard-index/);
});
diff --git a/scripts/__tests__/run-vitest-stable-shard.test.mjs b/scripts/__tests__/run-vitest-stable-shard.test.mjs
index f3ef491e8f1..ea74a3de127 100644
--- a/scripts/__tests__/run-vitest-stable-shard.test.mjs
+++ b/scripts/__tests__/run-vitest-stable-shard.test.mjs
@@ -77,9 +77,37 @@ test("a route/authz suite never leaks into the general-server shards", () => {
}
});
-test("shard flags are rejected for the parallel workspace groups", () => {
- const result = dryRun(["--mode", "general", "--group", "general-workspaces-a", "--shard-index", "0", "--shard-count", "3"]);
- assert.notEqual(result.status, 0, "workspace groups must not accept shard flags");
+test("shard flags are rejected for the workspaces-b group", () => {
+ const result = dryRun(["--mode", "general", "--group", "general-workspaces-b", "--shard-index", "0", "--shard-count", "3"]);
+ assert.notEqual(result.status, 0, "workspaces-b must not accept shard flags");
+});
+
+test("workspaces-a shards map to Vitest native --shard slices over a stable project list", () => {
+ const shards = [0, 1].map((index) =>
+ dryRunJson([
+ "--mode", "general", "--group", "general-workspaces-a",
+ "--shard-index", String(index), "--shard-count", "2",
+ ]),
+ );
+
+ assert.deepEqual(
+ shards.map((shard) => shard.workspacesVitestShard),
+ ["1/2", "2/2"],
+ "each matrix job must pass its own --shard slice to vitest",
+ );
+ // Vitest's --shard partitions each project's file list deterministically, so
+ // an identical project list across jobs is what guarantees complete,
+ // non-overlapping coverage of the lane.
+ assert.deepEqual(shards[0].workspaceProjects, shards[1].workspaceProjects);
+ assert.ok(shards[0].workspaceProjects.length > 0, "workspaces-a must run at least one project");
+
+ const unsharded = dryRunJson(["--mode", "general", "--group", "general-workspaces-a"]);
+ assert.deepEqual(
+ unsharded.workspaceProjects,
+ shards[0].workspaceProjects,
+ "sharding must not change which projects the lane covers",
+ );
+ assert.equal(unsharded.workspacesVitestShard, null);
});
test("duration-aware partition balances skewed weights better than round-robin", () => {
diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs
index 7c5a6c9c8d1..d8b334e58f0 100644
--- a/scripts/run-vitest-stable.mjs
+++ b/scripts/run-vitest-stable.mjs
@@ -208,10 +208,11 @@ function parseCliOptions(argv) {
const shardAllowed =
mode === serializedModeName ||
- (mode === generalModeName && group === generalServerGroupName);
+ (mode === generalModeName &&
+ (group === generalServerGroupName || group === generalWorkspacesAGroupName));
if (!shardAllowed && shardIndex !== null) {
fail(
- "--shard-index/--shard-count are only valid with --mode serialized or --mode general --group general-server.",
+ "--shard-index/--shard-count are only valid with --mode serialized, --mode general --group general-server, or --mode general --group general-workspaces-a.",
);
}
@@ -287,9 +288,16 @@ function runGeneralSuites(routeTests) {
}
}
-function runProjectGroup(projects, groupName) {
+function runProjectGroup(projects, groupName, shardIndex = null, shardCount = null) {
+ // With shard args, lean on Vitest's native --shard: each matrix job runs the
+ // same per-project invocations but only its slice of each project's test
+ // files. Vitest's sharding is deterministic for an identical file list, so
+ // the matrix jobs form a complete, non-overlapping cover of every project.
+ const shardArgs =
+ shardCount !== null && shardCount > 1 ? [`--shard=${shardIndex + 1}/${shardCount}`] : [];
+ const shardSuffix = shardArgs.length > 0 ? ` shard ${shardIndex + 1}/${shardCount}` : "";
for (const project of projects) {
- runVitest(["--project", project], `${groupName} project ${project}`);
+ runVitest(["--project", project, ...shardArgs], `${groupName} project ${project}${shardSuffix}`);
}
}
@@ -335,7 +343,11 @@ function runGeneralGroup(routeTests, groupName, shardIndex = null, shardCount =
}
if (groupName === generalWorkspacesAGroupName) {
- runProjectGroup(generalWorkspacesAProjects, groupName);
+ // The ui project dominates this lane (~224s of a 319s job in actions run
+ // 31371439296, 2026-08-10, where workspaces-a was the slowest PR check).
+ // Its 439 test files shard cleanly with Vitest's native --shard, so the
+ // lane splits across runners without a duration manifest.
+ runProjectGroup(generalWorkspacesAProjects, groupName, shardIndex, shardCount);
return;
}
@@ -416,6 +428,18 @@ if (options.dryRun) {
generalServerShardDurations,
)
: null,
+ workspaceProjects:
+ options.group === generalWorkspacesAGroupName
+ ? generalWorkspacesAProjects
+ : options.group === generalWorkspacesBGroupName
+ ? generalWorkspacesBProjects
+ : null,
+ workspacesVitestShard:
+ options.group === generalWorkspacesAGroupName &&
+ options.shardCount !== null &&
+ options.shardCount > 1
+ ? `${options.shardIndex + 1}/${options.shardCount}`
+ : null,
},
null,
2,
From 66575fe519db7320147aece94fa66e15eba375c1 Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Mon, 10 Aug 2026 21:45:58 -0400
Subject: [PATCH 031/201] fix(paperclip-page): scope uploader credentials to
the publish helper (#10894)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents publish static pages with the paperclip-page skill and its
`publish.sh` helper
> - The skill docs told operators to bind the page-uploader IAM keys as
the global `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
> - Static env keys have precedence over `AWS_PROFILE` in the AWS CLI
and in all AWS SDKs
> - Because of this, each agent run lost the host role identity and lost
access to Secrets Manager and other AWS services
> - This pull request adds namespaced credential variables that apply
only to the helper's own `aws` calls
> - The benefit is a stable host AWS identity in agent runs, with no
change to page publishing
## Linked Issues or Issue Description
No public GitHub issue exists. Description of the problem:
**What happened?**
Agent runs on a host with `AWS_PROFILE` set lost access to AWS Secrets
Manager. The failures looked intermittent. The cause is deterministic:
the page-uploader keys were bound as global `AWS_ACCESS_KEY_ID` /
`AWS_SECRET_ACCESS_KEY` in agent run environments. These static keys
shadow `AWS_PROFILE`. Each process in the agent run then used the
S3-upload-only uploader identity.
**Expected behavior**
The page-uploader credentials apply only to the page publish helper. All
other processes keep the host identity from `AWS_PROFILE`.
**Steps to reproduce**
1. Set `AWS_PROFILE` to a role with Secrets Manager access.
2. Export `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` for an IAM user
without that access.
3. Run `aws sts get-caller-identity`. The identity is the IAM user, not
the role.
4. Run `aws secretsmanager list-secrets`. The call fails with
`AccessDeniedException`.
## What Changed
- `publish.sh` reads `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` and
`PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`, with optional
`PAPERCLIP_PAGE_AWS_SESSION_TOKEN`.
- The helper applies these values only to its own `aws` invocations. It
clears ambient `AWS_PROFILE` and `AWS_SESSION_TOKEN` for those calls.
- Credential precedence is: namespaced key pair, then
`PAPERCLIP_PAGE_AWS_PROFILE`, then the ambient credential chain.
Existing global-name bindings continue to work during migration.
- Validation: the key pair must be set together. The pair plus
`PAPERCLIP_PAGE_AWS_PROFILE` is an error. A session token without the
pair is an error.
- `SKILL.md` and `README.md` now instruct operators to bind the secrets
under the namespaced names and explain the shadowing hazard.
## Verification
- Run `node --test
.agents/skills/paperclip-page/scripts/publish.test.mjs`. All 11 tests
pass.
- New tests cover: the incomplete key pair, the pair-plus-profile
conflict, the token-without-pair error, and a fake-`aws` environment
capture that proves the helper's calls see the page keys while
`AWS_PROFILE` and `AWS_SESSION_TOKEN` stay unset.
- Run `bash -n .agents/skills/paperclip-page/scripts/publish.sh` for a
syntax check.
## Risks
- Low risk. The change is contained in one skill helper and its
documents.
- The ambient credential chain remains the fallback, so current
deployments do not break before operators rebind the secrets.
- Operators must rebind the two page secrets to the namespaced names to
get the benefit. The README documents this.
## Model Used
Claude Fable 5 (`claude-fable-5`), Anthropic. Context window: 1,000,000
tokens (128K max output). Agentic coding session with extended thinking
and tool use (Claude Code harness).
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5
---
.agents/skills/paperclip-page/README.md | 36 ++-
.agents/skills/paperclip-page/SKILL.md | 14 +-
.../skills/paperclip-page/scripts/publish.sh | 52 ++++-
.../paperclip-page/scripts/publish.test.mjs | 209 ++++++++++++++++++
4 files changed, 300 insertions(+), 11 deletions(-)
diff --git a/.agents/skills/paperclip-page/README.md b/.agents/skills/paperclip-page/README.md
index 4ba859741b4..cfe2d878827 100644
--- a/.agents/skills/paperclip-page/README.md
+++ b/.agents/skills/paperclip-page/README.md
@@ -73,8 +73,8 @@ Required for live publishes:
export AWS_REGION=us-east-1
export PAPERCLIP_PAGE_BUCKET=paperclip-pages-prod
export PAPERCLIP_PAGE_BASE_URL=https://pages.paperclip.ing
-export AWS_ACCESS_KEY_ID=...
-export AWS_SECRET_ACCESS_KEY=...
+export PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID=...
+export PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY=...
```
Optional:
@@ -82,15 +82,35 @@ Optional:
```bash
export PAPERCLIP_PAGE_DEFAULT_PREFIX=""
export PAPERCLIP_PAGE_AWS_PROFILE=paperclip-page-uploader
+export PAPERCLIP_PAGE_AWS_SESSION_TOKEN=... # only with the namespaced key pair
```
+Credential resolution order inside `publish.sh`:
+
+1. `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` + `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
+ (scoped to the helper's `aws` calls; the surrounding process identity is
+ untouched)
+2. `PAPERCLIP_PAGE_AWS_PROFILE`, passed to `aws` as `--profile` (ambient
+ `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` /
+ `AWS_PROFILE` are stripped from the helper's `aws` calls so the named
+ profile always wins)
+3. The ambient AWS credential chain
+
+Setting both the namespaced key pair and `PAPERCLIP_PAGE_AWS_PROFILE` is an
+error.
+
Recommended Paperclip secret names:
- `paperclip-page-aws-access-key-id`
- `paperclip-page-aws-secret-access-key`
-Bind those secrets into publisher agents as `AWS_ACCESS_KEY_ID` and
-`AWS_SECRET_ACCESS_KEY`. Do not reuse Paperclip's internal S3 attachment/object
+Bind those secrets into publisher agents as `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID`
+and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`. Never bind them as the global
+`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` names: static env keys override
+`AWS_PROFILE` in the AWS CLI and every SDK, so global names silently switch the
+whole agent run — and every subprocess — to the page-uploader identity and
+break access to anything the uploader cannot reach (Secrets Manager, STS role
+use, other buckets). Do not reuse Paperclip's internal S3 attachment/object
storage credentials.
## AWS Setup
@@ -484,16 +504,18 @@ pnpm paperclipai secrets create \
--value-env PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
```
-Bind runtime env to publishing agents:
+Bind runtime env to publishing agents. Use the namespaced names — never the
+global `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, which would shadow the
+host `AWS_PROFILE` identity for the entire agent run:
```json
{
- "AWS_ACCESS_KEY_ID": {
+ "PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID": {
"type": "secret_ref",
"secretId": "",
"version": "latest"
},
- "AWS_SECRET_ACCESS_KEY": {
+ "PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY": {
"type": "secret_ref",
"secretId": "",
"version": "latest"
diff --git a/.agents/skills/paperclip-page/SKILL.md b/.agents/skills/paperclip-page/SKILL.md
index e9e74cfe085..df77444874d 100644
--- a/.agents/skills/paperclip-page/SKILL.md
+++ b/.agents/skills/paperclip-page/SKILL.md
@@ -19,10 +19,20 @@ host, for example `https://pages.paperclip.ing//`.
- `PAPERCLIP_PAGE_BUCKET`
- `PAPERCLIP_PAGE_BASE_URL`
- `AWS_REGION`
- - AWS credentials via Paperclip Secrets or an approved AWS vault
+ - `PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID` and `PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY`
+ with the page-uploader credentials from Paperclip Secrets
- Optional environment variables:
- `PAPERCLIP_PAGE_DEFAULT_PREFIX`
- - `PAPERCLIP_PAGE_AWS_PROFILE`
+ - `PAPERCLIP_PAGE_AWS_PROFILE` (alternative to the namespaced key pair)
+ - `PAPERCLIP_PAGE_AWS_SESSION_TOKEN` (only together with the namespaced key
+ pair)
+
+Do not bind the page-uploader credentials as global `AWS_ACCESS_KEY_ID` /
+`AWS_SECRET_ACCESS_KEY`: static env keys take precedence over `AWS_PROFILE` in
+every AWS SDK, so global names silently replace the host identity for every
+process in the agent run. The namespaced variables scope the uploader identity
+to this helper only. The ambient credential chain still works as a fallback
+when none of the `PAPERCLIP_PAGE_AWS_*` credential variables are set.
## Workflow
diff --git a/.agents/skills/paperclip-page/scripts/publish.sh b/.agents/skills/paperclip-page/scripts/publish.sh
index 2aa3a356add..086003a1e11 100755
--- a/.agents/skills/paperclip-page/scripts/publish.sh
+++ b/.agents/skills/paperclip-page/scripts/publish.sh
@@ -15,6 +15,15 @@ Required environment for live publish:
Optional environment:
PAPERCLIP_PAGE_DEFAULT_PREFIX, PAPERCLIP_PAGE_AWS_PROFILE
+ PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID, PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY,
+ PAPERCLIP_PAGE_AWS_SESSION_TOKEN
+
+Credential resolution for aws calls made by this helper:
+ 1. PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID + PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY
+ (used only by this helper; ambient AWS_PROFILE/AWS_* identity is untouched)
+ 2. PAPERCLIP_PAGE_AWS_PROFILE (passed as --profile; ambient AWS_* identity
+ variables are stripped from the helper's aws calls)
+ 3. Ambient AWS credential chain (env keys, profile, instance role)
Options:
--slug SLUG Lowercase URL slug. Allowed: a-z, 0-9, hyphen.
@@ -125,9 +134,26 @@ join_prefix() {
}
aws_base_args=()
+aws_env_unset=()
+aws_env_overrides=()
aws_cli() {
- aws "${aws_base_args[@]}" "$@"
+ local name pair
+ if [[ ${#aws_env_unset[@]} -gt 0 || ${#aws_env_overrides[@]} -gt 0 ]]; then
+ # Scope the page-uploader identity to this helper's aws calls only, and
+ # drop the ambient identity variables that would otherwise mix with or
+ # shadow the configured credential source. Apply the overrides with shell
+ # builtins in a subshell — passing them to an external `env` command would
+ # expose the credential values in its argv (world-readable via
+ # /proc//cmdline) while it runs.
+ (
+ for name in "${aws_env_unset[@]}"; do unset "$name"; done
+ for pair in "${aws_env_overrides[@]}"; do export "$pair"; done
+ exec aws "${aws_base_args[@]}" "$@"
+ )
+ else
+ aws "${aws_base_args[@]}" "$@"
+ fi
}
object_exists() {
@@ -297,6 +323,18 @@ default_prefix="$(normalize_default_prefix "${PAPERCLIP_PAGE_DEFAULT_PREFIX:-}")
[[ -n "$base_url" ]] || die "PAPERCLIP_PAGE_BASE_URL is required"
base_url="$(normalize_base_url "$base_url")"
+page_access_key_id="${PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID:-}"
+page_secret_access_key="${PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY:-}"
+if [[ -n "$page_access_key_id" || -n "$page_secret_access_key" ]]; then
+ [[ -n "$page_access_key_id" && -n "$page_secret_access_key" ]] ||
+ die "PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID and PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY must be set together"
+ [[ -z "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]] ||
+ die "set PAPERCLIP_PAGE_AWS_PROFILE or the PAPERCLIP_PAGE_AWS_* key pair, not both"
+fi
+if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" && -z "$page_access_key_id" ]]; then
+ die "PAPERCLIP_PAGE_AWS_SESSION_TOKEN requires the PAPERCLIP_PAGE_AWS_* key pair"
+fi
+
explicit_slug=0
if [[ -n "$slug_arg" ]]; then
explicit_slug=1
@@ -310,8 +348,18 @@ if [[ "$dry_run" == "0" ]]; then
require_command curl
[[ -n "$region" ]] || die "AWS_REGION is required for live publish"
aws_base_args=(--region "$region")
- if [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
+ if [[ -n "$page_access_key_id" ]]; then
+ aws_env_unset=(AWS_PROFILE AWS_SESSION_TOKEN)
+ aws_env_overrides=(
+ AWS_ACCESS_KEY_ID="$page_access_key_id"
+ AWS_SECRET_ACCESS_KEY="$page_secret_access_key"
+ )
+ if [[ -n "${PAPERCLIP_PAGE_AWS_SESSION_TOKEN:-}" ]]; then
+ aws_env_overrides+=(AWS_SESSION_TOKEN="$PAPERCLIP_PAGE_AWS_SESSION_TOKEN")
+ fi
+ elif [[ -n "${PAPERCLIP_PAGE_AWS_PROFILE:-}" ]]; then
aws_base_args+=(--profile "$PAPERCLIP_PAGE_AWS_PROFILE")
+ aws_env_unset=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE)
fi
fi
diff --git a/.agents/skills/paperclip-page/scripts/publish.test.mjs b/.agents/skills/paperclip-page/scripts/publish.test.mjs
index 8ef4b47bd2a..bb8384bfb0b 100644
--- a/.agents/skills/paperclip-page/scripts/publish.test.mjs
+++ b/.agents/skills/paperclip-page/scripts/publish.test.mjs
@@ -131,6 +131,215 @@ test("rejects hidden files in the source tree", () => {
assert.match(result.output, /hidden files and dot paths are not allowed/);
});
+test("namespaced page keys require both halves of the pair", () => {
+ const result = runPublish(
+ [createSite(), "--slug", "demo-page", "--dry-run"],
+ { PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER" },
+ );
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.output, /must be set together/);
+});
+
+test("namespaced session token requires the namespaced key pair", () => {
+ const result = runPublish(
+ [createSite(), "--slug", "demo-page", "--dry-run"],
+ { PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-token" },
+ );
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.output, /requires the PAPERCLIP_PAGE_AWS_\* key pair/);
+});
+
+test("namespaced page keys conflict with PAPERCLIP_PAGE_AWS_PROFILE", () => {
+ const result = runPublish(
+ [createSite(), "--slug", "demo-page", "--dry-run"],
+ {
+ PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
+ PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
+ PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
+ },
+ );
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.output, /not both/);
+});
+
+test("namespaced page keys are scoped to the helper's aws calls", () => {
+ const siteDir = createSite();
+ const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
+ tempDirs.add(binDir);
+ const envDump = join(binDir, "aws-env.txt");
+
+ writeExecutable(
+ join(binDir, "aws"),
+ `#!/usr/bin/env bash
+set -euo pipefail
+{
+ echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-}"
+ echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-}"
+ echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-}"
+ echo "AWS_PROFILE=\${AWS_PROFILE:-}"
+} >"${envDump}"
+while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
+ shift 2
+done
+if [[ "$1" == "s3api" ]]; then
+ echo "None"
+ exit 0
+fi
+if [[ "$1" == "s3" && "$2" == "sync" ]]; then
+ exit 0
+fi
+echo "unexpected aws call: $*" >&2
+exit 1
+`,
+ );
+ writeExecutable(
+ join(binDir, "curl"),
+ `#!/usr/bin/env bash
+exit 0
+`,
+ );
+
+ const result = runPublish([siteDir, "--slug", "demo-page"], {
+ AWS_REGION: "us-east-1",
+ PATH: `${binDir}:${process.env.PATH}`,
+ AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
+ AWS_SECRET_ACCESS_KEY: "ambient-secret",
+ AWS_SESSION_TOKEN: "ambient-session-token",
+ AWS_PROFILE: "ambient-profile",
+ PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
+ PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret",
+ });
+
+ assert.equal(result.status, 0);
+
+ const seen = readFileSync(envDump, "utf8");
+ assert.match(seen, /^AWS_ACCESS_KEY_ID=AKIAPAGEUPLOADER$/m);
+ assert.match(seen, /^AWS_SECRET_ACCESS_KEY=page-secret$/m);
+ assert.match(seen, /^AWS_SESSION_TOKEN=$/m);
+ assert.match(seen, /^AWS_PROFILE=$/m);
+});
+
+test("credential values never pass through an external env command's argv", () => {
+ const siteDir = createSite();
+ const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
+ tempDirs.add(binDir);
+ const envArgvDump = join(binDir, "env-argv.txt");
+
+ writeExecutable(
+ join(binDir, "aws"),
+ `#!/usr/bin/env bash
+set -euo pipefail
+while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
+ shift 2
+done
+if [[ "$1" == "s3api" ]]; then
+ echo "None"
+ exit 0
+fi
+if [[ "$1" == "s3" && "$2" == "sync" ]]; then
+ exit 0
+fi
+echo "unexpected aws call: $*" >&2
+exit 1
+`,
+ );
+ writeExecutable(
+ join(binDir, "curl"),
+ `#!/usr/bin/env bash
+exit 0
+`,
+ );
+ // Shim env: record every argv it is invoked with, then behave normally.
+ // Credentials in that argv would be world-readable via /proc//cmdline.
+ writeExecutable(
+ join(binDir, "env"),
+ `#!/bin/bash
+printf '%s\\n' "$@" >>"${envArgvDump}"
+exec /usr/bin/env "$@"
+`,
+ );
+
+ const result = runPublish([siteDir, "--slug", "demo-page"], {
+ AWS_REGION: "us-east-1",
+ PATH: `${binDir}:${process.env.PATH}`,
+ PAPERCLIP_PAGE_AWS_ACCESS_KEY_ID: "AKIAPAGEUPLOADER",
+ PAPERCLIP_PAGE_AWS_SECRET_ACCESS_KEY: "page-secret-argv-canary",
+ PAPERCLIP_PAGE_AWS_SESSION_TOKEN: "page-session-argv-canary",
+ });
+
+ assert.equal(result.status, 0);
+
+ const argvSeen = existsSync(envArgvDump) ? readFileSync(envArgvDump, "utf8") : "";
+ assert.doesNotMatch(argvSeen, /page-secret-argv-canary/);
+ assert.doesNotMatch(argvSeen, /page-session-argv-canary/);
+ assert.doesNotMatch(argvSeen, /AKIAPAGEUPLOADER/);
+});
+
+test("PAPERCLIP_PAGE_AWS_PROFILE strips ambient static credentials", () => {
+ const siteDir = createSite();
+ const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
+ tempDirs.add(binDir);
+ const envDump = join(binDir, "aws-env.txt");
+
+ writeExecutable(
+ join(binDir, "aws"),
+ `#!/usr/bin/env bash
+set -euo pipefail
+profile=""
+while [[ "$1" == "--region" || "$1" == "--profile" ]]; do
+ if [[ "$1" == "--profile" ]]; then
+ profile="$2"
+ fi
+ shift 2
+done
+{
+ echo "PROFILE_ARG=$profile"
+ echo "AWS_ACCESS_KEY_ID=\${AWS_ACCESS_KEY_ID:-}"
+ echo "AWS_SECRET_ACCESS_KEY=\${AWS_SECRET_ACCESS_KEY:-}"
+ echo "AWS_SESSION_TOKEN=\${AWS_SESSION_TOKEN:-}"
+ echo "AWS_PROFILE=\${AWS_PROFILE:-}"
+} >"${envDump}"
+if [[ "$1" == "s3api" ]]; then
+ echo "None"
+ exit 0
+fi
+if [[ "$1" == "s3" && "$2" == "sync" ]]; then
+ exit 0
+fi
+echo "unexpected aws call: $*" >&2
+exit 1
+`,
+ );
+ writeExecutable(
+ join(binDir, "curl"),
+ `#!/usr/bin/env bash
+exit 0
+`,
+ );
+
+ const result = runPublish([siteDir, "--slug", "demo-page"], {
+ AWS_REGION: "us-east-1",
+ PATH: `${binDir}:${process.env.PATH}`,
+ AWS_ACCESS_KEY_ID: "AKIAAMBIENTIDENTITY",
+ AWS_SECRET_ACCESS_KEY: "ambient-secret",
+ AWS_SESSION_TOKEN: "ambient-session-token",
+ AWS_PROFILE: "ambient-profile",
+ PAPERCLIP_PAGE_AWS_PROFILE: "paperclip-page-uploader",
+ });
+
+ assert.equal(result.status, 0);
+
+ const seen = readFileSync(envDump, "utf8");
+ assert.match(seen, /^PROFILE_ARG=paperclip-page-uploader$/m);
+ assert.match(seen, /^AWS_ACCESS_KEY_ID=$/m);
+ assert.match(seen, /^AWS_SECRET_ACCESS_KEY=$/m);
+ assert.match(seen, /^AWS_SESSION_TOKEN=$/m);
+ assert.match(seen, /^AWS_PROFILE=$/m);
+});
+
test("live publish writes state before URL verification", () => {
const siteDir = createSite();
const binDir = mkdtempSync(join(tmpdir(), "paperclip-page-bin-"));
From 9cdaa5416ed21b85059e2e724b992c846e92af2f Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 11 Aug 2026 07:09:54 -0400
Subject: [PATCH 032/201] fix(ui): remember folded inbox subtasks (#11069)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The inbox helps operators scan parent tasks and their sub-tasks
> - Operators can fold a parent task to hide its sub-tasks
> - The inbox previously forgot that fold state after a page refresh
> - This pull request stores the fold state for each company and
restores it when the inbox loads
> - The benefit is that the inbox keeps the operator's chosen task
layout across page refreshes
## Linked Issues or Issue Description
**What happened?**
The inbox reset every folded parent task after a page refresh. This made
all nested sub-tasks visible again.
**Expected behavior**
The inbox must keep each folded or unfolded parent state after a page
refresh. The state must remain separate for each company.
**Steps to reproduce**
1. Open the inbox with parent and child tasks.
2. Fold one parent task.
3. Refresh the page.
4. Observe that the child task is visible again without this fix.
**Paperclip version or commit**
Current `master` before this pull request.
**Deployment mode**
Local dev and built-from-source deployments.
## What Changed
- Added company-scoped local storage helpers for collapsed inbox parent
IDs.
- Restored the stored parent fold state when the inbox mounts or the
selected company changes.
- Saved both direct toggle changes and explicit collapse changes.
- Added helper tests and an inbox remount regression test for both
folded and unfolded states.
## Verification
- `pnpm exec vitest run ui/src/lib/inbox.test.ts
ui/src/pages/Inbox.test.tsx` — 77 tests passed.
- `pnpm check:token-gates` — all gates passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm --filter @paperclipai/ui build` — passed.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — 3,521 tests passed and four skipped. One unrelated
server test on the current base fails because it reads
`heartbeat.scheduling_suppressed` instead of `issue_commented`; the same
test fails alone and this pull request changes only inbox UI files.
## Risks
- Low risk. The state is local to the browser and scoped by company ID.
- Old parent IDs can remain in local storage after tasks are deleted,
but they do not affect visible tasks.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex, exact model ID `gpt-5.6-sol`, with reasoning, tool use,
and code execution. The runtime does not expose its context-window size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
ui/src/lib/inbox.test.ts | 19 ++++++++++
ui/src/lib/inbox.ts | 36 ++++++++++++++++++
ui/src/pages/Inbox.test.tsx | 74 +++++++++++++++++++++++++++++++++++++
ui/src/pages/Inbox.tsx | 13 +++++--
4 files changed, 139 insertions(+), 3 deletions(-)
diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts
index 76deef856be..3fdf2ef02a9 100644
--- a/ui/src/lib/inbox.test.ts
+++ b/ui/src/lib/inbox.test.ts
@@ -35,6 +35,7 @@ import {
loadInboxIssueColumns,
loadInboxWorkItemGroupBy,
loadCollapsedInboxGroupKeys,
+ loadCollapsedInboxParentIds,
loadLastInboxTab,
matchesInboxIssueSearch,
normalizeInboxIssueColumns,
@@ -45,6 +46,7 @@ import {
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveCollapsedInboxGroupKeys,
+ saveCollapsedInboxParentIds,
saveInboxIssueColumns,
saveInboxWorkItemGroupBy,
saveLastInboxTab,
@@ -1560,6 +1562,23 @@ describe("inbox helpers", () => {
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set());
});
+ it("persists collapsed inbox parents per company", () => {
+ saveCollapsedInboxParentIds("company-1", new Set(["parent-1", "parent-2"]));
+ saveCollapsedInboxParentIds("company-2", new Set(["parent-3"]));
+
+ expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set(["parent-1", "parent-2"]));
+ expect(loadCollapsedInboxParentIds("company-2")).toEqual(new Set(["parent-3"]));
+
+ saveCollapsedInboxParentIds("company-1", new Set());
+ expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
+ });
+
+ it("returns empty collapsed inbox parents for missing or invalid storage", () => {
+ expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
+ localStorage.setItem("paperclip:inbox:collapsed-parents:company-1", JSON.stringify({ nope: true }));
+ expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
+ });
+
it("does not reset workspace grouping before experimental settings have loaded", () => {
expect(shouldResetInboxWorkspaceGrouping("workspace", false, false)).toBe(false);
});
diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts
index be0e9145f77..284a1163a24 100644
--- a/ui/src/lib/inbox.ts
+++ b/ui/src/lib/inbox.ts
@@ -26,6 +26,7 @@ export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
+export const INBOX_COLLAPSED_PARENTS_KEY_PREFIX = "paperclip:inbox:collapsed-parents";
export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all";
export type InboxCategoryFilter =
| "everything"
@@ -187,6 +188,11 @@ function getInboxCollapsedGroupsStorageKey(companyId: string | null | undefined)
return `${INBOX_COLLAPSED_GROUPS_KEY_PREFIX}:${companyId}`;
}
+function getInboxCollapsedParentsStorageKey(companyId: string | null | undefined): string | null {
+ if (!companyId) return null;
+ return `${INBOX_COLLAPSED_PARENTS_KEY_PREFIX}:${companyId}`;
+}
+
export function loadInboxFilterPreferences(
companyId: string | null | undefined,
): InboxFilterPreferences {
@@ -271,6 +277,36 @@ export function saveCollapsedInboxGroupKeys(
}
}
+export function loadCollapsedInboxParentIds(
+ companyId: string | null | undefined,
+): Set {
+ const storageKey = getInboxCollapsedParentsStorageKey(companyId);
+ if (!storageKey) return new Set();
+
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (!raw) return new Set();
+ const parsed = JSON.parse(raw);
+ return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []);
+ } catch {
+ return new Set();
+ }
+}
+
+export function saveCollapsedInboxParentIds(
+ companyId: string | null | undefined,
+ parentIds: ReadonlySet,
+) {
+ const storageKey = getInboxCollapsedParentsStorageKey(companyId);
+ if (!storageKey) return;
+
+ try {
+ localStorage.setItem(storageKey, JSON.stringify([...parentIds]));
+ } catch {
+ // Ignore localStorage failures.
+ }
+}
+
export function loadDismissedInboxAlerts(): Set {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx
index 2879a6841dd..4e5cb53bb5f 100644
--- a/ui/src/pages/Inbox.test.tsx
+++ b/ui/src/pages/Inbox.test.tsx
@@ -345,6 +345,80 @@ describe("Inbox toolbar", () => {
act(() => root.unmount());
});
+ it("restores folded and unfolded sub-tasks across remounts", async () => {
+ routerMock.location.pathname = "/inbox/mine";
+ const storageKey = "paperclip:inbox:collapsed-parents:company-1";
+ localStorage.removeItem(storageKey);
+
+ const parent = createIssue({
+ id: "parent-issue",
+ identifier: "PAP-1001",
+ title: "Parent inbox task",
+ });
+ const child = createIssue({
+ id: "child-issue",
+ identifier: "PAP-1002",
+ parentId: parent.id,
+ title: "Nested inbox task",
+ });
+ apiMocks.issuesList.mockResolvedValue([parent, child]);
+
+ const mountInbox = async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
+ });
+ const root = createRoot(container);
+ await act(async () => {
+ root.render(
+
+
+ ,
+ );
+ });
+ await vi.waitFor(() => {
+ expect(container.textContent).toContain(parent.title);
+ });
+ return root;
+ };
+ const parentToggle = () => {
+ const parentRow = Array.from(container.querySelectorAll("[data-inbox-item]"))
+ .find((row) => row.textContent?.includes(parent.title));
+ return parentRow?.querySelector('button[data-slot="icon-button"]') ?? null;
+ };
+
+ let root = await mountInbox();
+ try {
+ expect(container.textContent).toContain(child.title);
+
+ await act(async () => {
+ parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await vi.waitFor(() => {
+ expect(container.textContent).not.toContain(child.title);
+ });
+ expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([parent.id]);
+
+ act(() => root.unmount());
+ root = await mountInbox();
+ expect(container.textContent).not.toContain(child.title);
+
+ await act(async () => {
+ parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ await vi.waitFor(() => {
+ expect(container.textContent).toContain(child.title);
+ });
+ expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([]);
+
+ act(() => root.unmount());
+ root = await mountInbox();
+ expect(container.textContent).toContain(child.title);
+ } finally {
+ localStorage.removeItem(storageKey);
+ act(() => root.unmount());
+ }
+ });
+
it("shows blocked toolbar controls on the Blocked tab", async () => {
routerMock.location.pathname = "/inbox/blocked";
const queryClient = new QueryClient({
diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx
index e9abc46966e..4bf93eb0d49 100644
--- a/ui/src/pages/Inbox.tsx
+++ b/ui/src/pages/Inbox.tsx
@@ -144,6 +144,7 @@ import {
isInboxEntityDismissed,
isMineInboxTab,
loadCollapsedInboxGroupKeys,
+ loadCollapsedInboxParentIds,
loadInboxFilterPreferences,
loadInboxIssueColumns,
loadInboxNesting,
@@ -155,6 +156,7 @@ import {
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveCollapsedInboxGroupKeys,
+ saveCollapsedInboxParentIds,
saveInboxIssueColumns,
saveInboxNesting,
saveInboxWorkItemGroupBy,
@@ -801,6 +803,7 @@ export function Inbox() {
previousSelectedCompanyIdRef.current = selectedCompanyId;
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
setCollapsedGroupKeys(loadCollapsedInboxGroupKeys(selectedCompanyId));
+ setCollapsedInboxParents(loadCollapsedInboxParentIds(selectedCompanyId));
}
}, [selectedCompanyId]);
@@ -1372,7 +1375,9 @@ export function Inbox() {
return next;
});
}, []);
- const [collapsedInboxParents, setCollapsedInboxParents] = useState>(new Set());
+ const [collapsedInboxParents, setCollapsedInboxParents] = useState>(
+ () => loadCollapsedInboxParentIds(selectedCompanyId),
+ );
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => loadCollapsedInboxGroupKeys(selectedCompanyId));
const toggleGroupCollapse = useCallback((groupKey: string) => {
setCollapsedGroupKeys((prev) => {
@@ -1508,18 +1513,20 @@ export function Inbox() {
const next = new Set(prev);
if (next.has(parentId)) next.delete(parentId);
else next.add(parentId);
+ saveCollapsedInboxParentIds(selectedCompanyId, next);
return next;
});
- }, []);
+ }, [selectedCompanyId]);
const setInboxParentCollapsed = useCallback((parentId: string, collapsed: boolean) => {
setCollapsedInboxParents((prev) => {
if (prev.has(parentId) === collapsed) return prev;
const next = new Set(prev);
if (collapsed) next.add(parentId);
else next.delete(parentId);
+ saveCollapsedInboxParentIds(selectedCompanyId, next);
return next;
});
- }, []);
+ }, [selectedCompanyId]);
// Build flat navigation list from visible rows so keyboard traversal respects collapsed groups.
const flatNavItems = useMemo((): NavEntry[] => {
From b58ce27a02840365d9eac591c2b7c66b2cef117a Mon Sep 17 00:00:00 2001
From: Dotta <34892728+cryppadotta@users.noreply.github.com>
Date: Tue, 11 Aug 2026 08:56:32 -0400
Subject: [PATCH 033/201] fix: isolate execution workspace summaries (#10790)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip gives operators a summary for each workspace.
> - An execution workspace detail page used the parent project-workspace
summary slot.
> - Two execution workspaces under one project workspace could therefore
show the same summary.
> - This pull request gives each execution workspace its own summary
scope.
> - It also limits the summary snapshot and generated issue to that
execution workspace.
> - The benefit is that a new or parallel execution workspace cannot
inherit unrelated status.
## Linked Issues or Issue Description
**What happened?**
An execution workspace detail page read and refreshed the summary slot
for its parent project workspace. Parallel execution workspaces could
show the same status and include issues from each other.
**Expected behavior**
Each execution workspace must have one isolated summary slot. Its
generated snapshot must include only issues assigned to that execution
workspace.
**Steps to reproduce**
1. Create two execution workspaces under one project workspace.
2. Add different issues to each execution workspace.
3. Generate the summary in the first execution workspace.
4. Open the second execution workspace.
5. Observe that the old implementation could reuse the first summary.
**Paperclip version or commit**
The problem exists on `master` before this pull request.
**Deployment mode**
The issue affects both local trusted and authenticated deployments.
## What Changed
- Added `execution_workspace` to the shared summary-slot scope contract.
- Validated execution-workspace ownership and stored generated summary
issues on the correct execution workspace.
- Limited execution-workspace snapshots to issues with the matching
execution workspace ID.
- Updated the execution workspace page to use its own summary slot.
- Updated Summarizer instructions, routine options, catalog metadata,
documentation, and regression tests.
## Verification
- `NODE_ENV=test pnpm exec vitest run
packages/shared/src/summary-slot.test.ts
server/src/__tests__/summary-slots.test.ts
ui/src/pages/ExecutionWorkspaceDetail.test.tsx` — 30 focused tests
passed; the embedded-Postgres server tests were run outside the
process-restricted sandbox.
- `pnpm check:token-gates` — passed.
- `pnpm --filter @paperclipai/skills-catalog validate` — passed with 17
catalog skills.
- [Latest-head GitHub
Actions](https://github.com/paperclipai/paperclip/actions/runs/31491475405)
— all 22 jobs passed on `beea14cbaf`, including typecheck, build,
server/workspace tests, serialized suites, e2e, canary, and aggregate
verification. One unrelated adapter cleanup test initially hit an
`ENOTEMPTY` temp-directory race; its single permitted rerun passed.
- Greptile — 5/5 confidence on `beea14cbaf`, 12 files reviewed, zero
comments added, and zero unresolved threads.
## Risks
- Low risk. The new scope is additive.
- Existing project and project-workspace summary slots keep their
current keys and behavior.
- A summary generated for an execution workspace now excludes sibling
workspace issues by design.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
- OpenAI Codex with GPT-5. The deployment does not expose a more
specific model ID or context-window value. It used agentic reasoning,
repository tools, code execution, and GitHub tooling.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip
---
doc/SPEC-implementation.md | 1 +
packages/shared/src/constants.ts | 7 +-
packages/shared/src/summary-slot.test.ts | 12 +-
.../summarize-status/SKILL.md | 9 +-
.../skills-catalog/generated/catalog.json | 6 +-
server/src/__tests__/summary-slots.test.ts | 111 ++++++++++++++++++
.../src/built-ins/agents/summarizer/AGENTS.md | 4 +-
.../routines/refresh-stale-summaries.md | 1 +
server/src/services/built-in-agents.ts | 8 +-
server/src/services/summary-slots.ts | 44 ++++++-
.../pages/ExecutionWorkspaceDetail.test.tsx | 16 ++-
ui/src/pages/ExecutionWorkspaceDetail.tsx | 16 ++-
12 files changed, 200 insertions(+), 35 deletions(-)
diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md
index e11298ef817..64b58326d13 100644
--- a/doc/SPEC-implementation.md
+++ b/doc/SPEC-implementation.md
@@ -1032,6 +1032,7 @@ Dashboard payload must include:
The current app also exposes V1-supporting surfaces for:
+- company-scoped summary slots for projects, the workspaces overview, project workspaces, and individual execution workspaces; execution-workspace slots are keyed by execution workspace id so a new workspace never inherits another workspace's summary
- issue thread interactions (`suggest_tasks`, `ask_user_questions`, `request_confirmation`)
- issue approvals, issue references/search, labels, read state, inbox/archive state, and work products
- company search through `GET /companies/:companyId/search` plus agent-oriented bulk extraction through
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index edad4e924fc..75c16a15360 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -221,7 +221,12 @@ export const ISSUE_HARNESS_KINDS = ["skill_test"] as const;
export type IssueHarnessKind = (typeof ISSUE_HARNESS_KINDS)[number];
export const MAX_ISSUE_REQUEST_DEPTH = 1024;
-export const SUMMARY_SLOT_SCOPE_KINDS = ["project", "workspaces_overview", "project_workspace"] as const;
+export const SUMMARY_SLOT_SCOPE_KINDS = [
+ "project",
+ "workspaces_overview",
+ "project_workspace",
+ "execution_workspace",
+] as const;
export type SummarySlotScopeKind = (typeof SUMMARY_SLOT_SCOPE_KINDS)[number];
export const SUMMARY_SLOT_KEYS = ["header"] as const;
export type SummarySlotKey = (typeof SUMMARY_SLOT_KEYS)[number];
diff --git a/packages/shared/src/summary-slot.test.ts b/packages/shared/src/summary-slot.test.ts
index 46c1de5f496..5f8e222dea4 100644
--- a/packages/shared/src/summary-slot.test.ts
+++ b/packages/shared/src/summary-slot.test.ts
@@ -20,7 +20,7 @@ describe("summary slot shared contract", () => {
);
});
- it("allows scoped project and project-workspace header slots", () => {
+ it("allows scoped project, project-workspace, and execution-workspace header slots", () => {
expect(summarySlotScopeSelectorSchema.parse({
scopeKind: "project",
scopeId,
@@ -32,6 +32,12 @@ describe("summary slot shared contract", () => {
scopeId,
slotKey: "header",
})).toEqual({ scopeKind: "project_workspace", scopeId, slotKey: "header" });
+
+ expect(summarySlotScopeSelectorSchema.parse({
+ scopeKind: "execution_workspace",
+ scopeId,
+ slotKey: "header",
+ })).toEqual({ scopeKind: "execution_workspace", scopeId, slotKey: "header" });
});
it("treats workspaces_overview as a company-scoped singleton", () => {
@@ -56,6 +62,10 @@ describe("summary slot shared contract", () => {
scopeKind: "project_workspace",
slotKey: "header",
})).toThrow("project_workspace summary slots require scopeId");
+ expect(() => summarySlotScopeSelectorSchema.parse({
+ scopeKind: "execution_workspace",
+ slotKey: "header",
+ })).toThrow("execution_workspace summary slots require scopeId");
});
it("validates summary write payload revision and generation metadata", () => {
diff --git a/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md b/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md
index fe14c3840f6..fb303e99d33 100644
--- a/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md
+++ b/packages/skills-catalog/catalog/bundled/paperclip-operations/summarize-status/SKILL.md
@@ -15,7 +15,7 @@ tags:
# Summarize status
-You are the Summarizer. Turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's **summary slot** as a new revision.
+You are the Summarizer. Turn the current state of a Paperclip scope — a project, the workspaces overview, a project workspace, or a specific execution workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's **summary slot** as a new revision.
**Open with what the reader needs to do.** The first thing in every summary is 1–3 specific, concrete, actionable items the reader should do right now to unblock this tree of work — "merge the install PR", "answer the org-accounts question", "approve the OAuth plan". Each item says what to do and why it's the thing holding up progress, with an inline link. This is the whole point of the summary: someone glances at the card and knows exactly what to do next. If genuinely nothing needs them, say so plainly in one line and name the next thing worth watching — never pad with filler actions.
@@ -27,7 +27,7 @@ This is a **read-and-report** loop. You never change the underlying issues, work
## When to use
-- A summary-generation issue is assigned to you naming a scope (`project`, `workspaces_overview`, or `project_workspace`) and slot (`header`).
+- A summary-generation issue is assigned to you naming a scope (`project`, `workspaces_overview`, `project_workspace`, or `execution_workspace`) and slot (`header`).
- A board user clicked **Generate** / **Refresh** on a summary card and Paperclip created work for you.
- A paused refresh routine you own is manually run or its schedule is enabled by an operator.
@@ -41,8 +41,8 @@ This is a **read-and-report** loop. You never change the underlying issues, work
From the generation issue / run context:
-- `scopeKind` — `project`, `workspaces_overview`, or `project_workspace`.
-- `scopeId` — the project or project-workspace id. Omitted for `workspaces_overview` (it has no scopeId).
+- `scopeKind` — `project`, `workspaces_overview`, `project_workspace`, or `execution_workspace`.
+- `scopeId` — the project, project-workspace, or execution-workspace id. Omitted for `workspaces_overview` (it has no scopeId).
- `slotKey` — currently always `header`.
- `generationIssueId` — the issue that requested this summary; pass it back so the slot records what produced the revision.
- The previous revision (if any) — read it so you can tell what's new and lead with that instead of repeating what the reader already saw.
@@ -55,6 +55,7 @@ Use these routes directly. Do not guess unscoped `/api/issues` or alternate summ
- Read the current slot: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}?scopeId=...`
- Read revision history only when the current-slot response is missing its latest document: `GET /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}/revisions?scopeId=...`
- Gather project issues: `GET /api/companies/{companyId}/issues?projectId=...`
+- Gather execution-workspace issues: `GET /api/companies/{companyId}/issues?executionWorkspaceId=...`
- Write the new revision: `PUT /api/companies/{companyId}/summary-slots/{scopeKind}/{slotKey}` with `scopeId`, `markdown`, `changeSummary`, `baseRevisionId`, `generationIssueId`, and `model` in the JSON body.
For `workspaces_overview`, omit `scopeId` from the read query and send it as `null` in the write body. All calls use the run-scoped Paperclip API URL and bearer token already present in the environment.
diff --git a/packages/skills-catalog/generated/catalog.json b/packages/skills-catalog/generated/catalog.json
index ad63e175690..d126606ea16 100644
--- a/packages/skills-catalog/generated/catalog.json
+++ b/packages/skills-catalog/generated/catalog.json
@@ -172,11 +172,11 @@
{
"path": "SKILL.md",
"kind": "skill",
- "sizeBytes": 8682,
- "sha256": "c5f459ced4e97e6ae33c3ffbe7b3fb7d4c1fe7bfb0121187f0c2f9000a670b37"
+ "sizeBytes": 8877,
+ "sha256": "4cb3d177f5d16e3cc2052d2f80e7fb245ab95e79c1e947abdc84cd4ee3d8c61e"
}
],
- "contentHash": "sha256:32d2f231a35fc3a658b244f13dd726b2f2bc642db6d3512559fdf9a2b680838d"
+ "contentHash": "sha256:3e49ee2b8d83d1371fb0f59197bb96c46b64f722540405f46d4a5a756fbe1b89"
},
{
"id": "paperclipai:bundled:paperclip-operations:task-planning",
diff --git a/server/src/__tests__/summary-slots.test.ts b/server/src/__tests__/summary-slots.test.ts
index 2bbb1fd5178..ac9fba3bff5 100644
--- a/server/src/__tests__/summary-slots.test.ts
+++ b/server/src/__tests__/summary-slots.test.ts
@@ -8,6 +8,7 @@ import {
createDb,
documentRevisions,
documents,
+ executionWorkspaces,
heartbeatRuns,
issues,
projectWorkspaces,
@@ -50,6 +51,7 @@ describeEmbeddedPostgres("summary slot service", () => {
await db.delete(documents);
await db.delete(issues);
await db.delete(heartbeatRuns);
+ await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(activityLog);
@@ -78,6 +80,35 @@ describeEmbeddedPostgres("summary slot service", () => {
return projectId;
}
+ async function seedProjectWorkspace(companyId: string, projectId: string) {
+ const projectWorkspaceId = randomUUID();
+ await db.insert(projectWorkspaces).values({
+ id: projectWorkspaceId,
+ companyId,
+ projectId,
+ name: "Primary workspace",
+ });
+ return projectWorkspaceId;
+ }
+
+ async function seedExecutionWorkspace(
+ companyId: string,
+ projectId: string,
+ projectWorkspaceId: string | null = null,
+ ) {
+ const executionWorkspaceId = randomUUID();
+ await db.insert(executionWorkspaces).values({
+ id: executionWorkspaceId,
+ companyId,
+ projectId,
+ projectWorkspaceId,
+ mode: "isolated_workspace",
+ strategyType: "git_worktree",
+ name: `Execution workspace ${executionWorkspaceId}`,
+ });
+ return executionWorkspaceId;
+ }
+
async function seedSummarizer(companyId: string, ready = true) {
const agentId = randomUUID();
await db.insert(agents).values({
@@ -117,6 +148,15 @@ describeEmbeddedPostgres("summary slot service", () => {
return { companyId, scopeKind: "project", slotKey: "header", scopeId: projectId };
}
+ function executionWorkspaceSelector(companyId: string, executionWorkspaceId: string) {
+ return {
+ companyId,
+ scopeKind: "execution_workspace",
+ slotKey: "header",
+ scopeId: executionWorkspaceId,
+ };
+ }
+
describe("reads and target visibility", () => {
it("returns an empty slot state before any generation", async () => {
const companyId = await seedCompany();
@@ -144,6 +184,18 @@ describeEmbeddedPostgres("summary slot service", () => {
});
});
+ it("rejects an execution workspace owned by another company", async () => {
+ const companyId = await seedCompany();
+ const otherCompanyId = await seedCompany();
+ const otherProjectId = await seedProject(otherCompanyId);
+ const foreignExecutionWorkspaceId = await seedExecutionWorkspace(otherCompanyId, otherProjectId);
+ const svc = summarySlotService(db);
+
+ await expect(
+ svc.getSlot(executionWorkspaceSelector(companyId, foreignExecutionWorkspaceId)),
+ ).rejects.toMatchObject({ status: 404 });
+ });
+
it("rejects a workspaces_overview selector that carries a scopeId", async () => {
const companyId = await seedCompany();
const svc = summarySlotService(db);
@@ -268,6 +320,65 @@ describeEmbeddedPostgres("summary slot service", () => {
expect(issueRow.description).not.toContain("Other project issue");
});
+ it("keeps summaries and snapshots isolated between execution workspaces", async () => {
+ const companyId = await seedCompany();
+ const projectId = await seedProject(companyId);
+ const projectWorkspaceId = await seedProjectWorkspace(companyId, projectId);
+ const firstExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
+ const secondExecutionWorkspaceId = await seedExecutionWorkspace(companyId, projectId, projectWorkspaceId);
+ await seedSummarizer(companyId);
+ const svc = summarySlotService(db);
+
+ await db.insert(issues).values([
+ {
+ companyId,
+ projectId,
+ projectWorkspaceId,
+ executionWorkspaceId: firstExecutionWorkspaceId,
+ identifier: `${issuePrefix(companyId)}-201`,
+ issueNumber: 201,
+ title: "First workspace task",
+ status: "in_progress",
+ priority: "medium",
+ },
+ {
+ companyId,
+ projectId,
+ projectWorkspaceId,
+ executionWorkspaceId: secondExecutionWorkspaceId,
+ identifier: `${issuePrefix(companyId)}-202`,
+ issueNumber: 202,
+ title: "Second workspace task",
+ status: "blocked",
+ priority: "high",
+ },
+ ]);
+
+ const firstSelector = executionWorkspaceSelector(companyId, firstExecutionWorkspaceId);
+ const secondSelector = executionWorkspaceSelector(companyId, secondExecutionWorkspaceId);
+ const generated = await svc.generate(firstSelector, { userId: "board-user" });
+
+ expect(generated.slot).toMatchObject({
+ scopeKind: "execution_workspace",
+ scopeId: firstExecutionWorkspaceId,
+ status: "generating",
+ });
+ const generationIssue = await db
+ .select()
+ .from(issues)
+ .where(eq(issues.id, generated.generatingIssue.id))
+ .then((rows) => rows[0]!);
+ expect(generationIssue.description).toContain("First workspace task");
+ expect(generationIssue.description).not.toContain("Second workspace task");
+ expect(generationIssue.description).toContain('"scopeKind": "execution_workspace"');
+ expect(generationIssue.description).toContain(`"scopeId": "${firstExecutionWorkspaceId}"`);
+ await expect(svc.getSlot(secondSelector)).resolves.toEqual({
+ slot: null,
+ document: null,
+ generatingIssue: null,
+ });
+ });
+
it("dedupes concurrent generate clicks without creating an orphan task", async () => {
const companyId = await seedCompany();
const projectId = await seedProject(companyId);
diff --git a/server/src/built-ins/agents/summarizer/AGENTS.md b/server/src/built-ins/agents/summarizer/AGENTS.md
index abc7f4b696e..60c9a3ea7fa 100644
--- a/server/src/built-ins/agents/summarizer/AGENTS.md
+++ b/server/src/built-ins/agents/summarizer/AGENTS.md
@@ -2,11 +2,11 @@ You are Summarizer, a built-in reporting agent at Paperclip.
When you wake up, follow the Paperclip heartbeat procedure. Work only on issues assigned to you. Always leave a task comment before exiting a heartbeat.
-Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, or a single project workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. When an issue asks you to generate or refresh a summary, use the `summarize-status` skill as your operating procedure and start with its API quick reference instead of discovering routes.
+Your job is to turn the current state of a Paperclip scope — a project, the workspaces overview, a project workspace, or a specific execution workspace — into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. When an issue asks you to generate or refresh a summary, use the `summarize-status` skill as your operating procedure and start with its API quick reference instead of discovering routes.
## Core responsibilities
-- Read the scope named by the generation issue (`scopeKind` = `project` | `workspaces_overview` | `project_workspace`, plus `scopeId` and `slotKey`).
+- Read the scope named by the generation issue (`scopeKind` = `project` | `workspaces_overview` | `project_workspace` | `execution_workspace`, plus `scopeId` and `slotKey`).
- Read the summary slot's most recent revision first, so you lead with what's new instead of repeating a headline the reader already saw.
- Triage, don't enumerate: from everything in the scope, work out the 1–3 specific, concrete actions the reader should take right now to unblock the work, and leave everything else off the page. Read whatever issues, comments, or blocker chains you need to genuinely understand where things are.
- Open every summary with those 1–3 actionable items — each saying what to do and why it's the thing holding up progress, with an inline link. If genuinely nothing needs the reader, say so plainly in one line and name the next thing worth watching.
diff --git a/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md b/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md
index 4c051a831ba..8920f0ae037 100644
--- a/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md
+++ b/server/src/built-ins/agents/summarizer/routines/refresh-stale-summaries.md
@@ -32,6 +32,7 @@ variables:
- project
- workspaces_overview
- project_workspace
+ - execution_workspace
triggers:
- kind: schedule
label: Daily stale-summary refresh
diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts
index b8a25460c72..40c46efe72c 100644
--- a/server/src/services/built-in-agents.ts
+++ b/server/src/services/built-in-agents.ts
@@ -181,7 +181,7 @@ const FALLBACK_REFLECTION_COACH_SKILL = [
const FALLBACK_SUMMARIZER_INSTRUCTIONS = [
"You are Summarizer, a built-in reporting agent at Paperclip.",
"",
- "Turn the current state of a Paperclip scope (project, workspaces overview, or a single project workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.",
+ "Turn the current state of a Paperclip scope (project, workspaces overview, project workspace, or execution workspace) into a short, honest, human-readable Markdown summary and write it back to that scope's summary slot as a new revision. Use the `summarize-status` skill as your operating procedure.",
"",
"Read-and-report only: never change issues, workspaces, or code. Cite issue identifiers, never fabricate status, keep every read company-scoped, and run on the low-cost model profile lane by default.",
"",
@@ -401,7 +401,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
displayName: "Summarizer",
featureKeys: ["summarizer"],
shortPurpose:
- "Writes short, human-readable Markdown status summaries into project, workspaces-overview, and project-workspace summary slots on demand.",
+ "Writes short, human-readable Markdown status summaries into project, workspaces-overview, project-workspace, and execution-workspace summary slots on demand.",
defaultInstructions: SUMMARIZER_INSTRUCTIONS,
defaultRole: "general",
defaultTitle: "Summarizer",
@@ -419,7 +419,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
},
defaultBudgetMonthlyCents: 0,
bundle: {
- stockVersion: "2026-07-15",
+ stockVersion: "2026-08-02",
instructions: {
entryFile: "AGENTS.md",
files: {
@@ -452,7 +452,7 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
type: "select",
defaultValue: "all",
required: true,
- options: ["all", "project", "workspaces_overview", "project_workspace"],
+ options: ["all", "project", "workspaces_overview", "project_workspace", "execution_workspace"],
},
],
triggers: [
diff --git a/server/src/services/summary-slots.ts b/server/src/services/summary-slots.ts
index bcd6f0c5436..6f07b83872c 100644
--- a/server/src/services/summary-slots.ts
+++ b/server/src/services/summary-slots.ts
@@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
import {
documentRevisions,
documents,
+ executionWorkspaces,
issues,
projectWorkspaces,
projects,
@@ -123,7 +124,9 @@ function scopeLabel(scopeKind: SummarySlotScopeKind): string {
case "project":
return "project";
case "project_workspace":
- return "workspace";
+ return "project workspace";
+ case "execution_workspace":
+ return "execution workspace";
case "workspaces_overview":
return "workspaces overview";
default:
@@ -175,6 +178,15 @@ export function summarySlotService(db: Db) {
.where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Summary target not found");
+ return;
+ }
+ if (sel.scopeKind === "execution_workspace") {
+ const row = await db
+ .select({ id: executionWorkspaces.id })
+ .from(executionWorkspaces)
+ .where(and(eq(executionWorkspaces.id, sel.scopeId), eq(executionWorkspaces.companyId, sel.companyId)))
+ .then((rows) => rows[0] ?? null);
+ if (!row) throw notFound("Summary target not found");
}
}
@@ -299,9 +311,10 @@ export function summarySlotService(db: Db) {
async function resolveGenerationTargetProject(sel: ResolvedSelector): Promise<{
projectId: string | null;
projectWorkspaceId: string | null;
+ executionWorkspaceId: string | null;
}> {
if (sel.scopeKind === "project") {
- return { projectId: sel.scopeId, projectWorkspaceId: null };
+ return { projectId: sel.scopeId, projectWorkspaceId: null, executionWorkspaceId: null };
}
if (sel.scopeKind === "project_workspace" && sel.scopeId) {
const row = await db
@@ -309,14 +322,34 @@ export function summarySlotService(db: Db) {
.from(projectWorkspaces)
.where(and(eq(projectWorkspaces.id, sel.scopeId), eq(projectWorkspaces.companyId, sel.companyId)))
.then((rows) => rows[0] ?? null);
- return { projectId: row?.projectId ?? null, projectWorkspaceId: sel.scopeId };
+ return {
+ projectId: row?.projectId ?? null,
+ projectWorkspaceId: sel.scopeId,
+ executionWorkspaceId: null,
+ };
}
- return { projectId: null, projectWorkspaceId: null };
+ if (sel.scopeKind === "execution_workspace" && sel.scopeId) {
+ const row = await db
+ .select({
+ projectId: executionWorkspaces.projectId,
+ projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
+ })
+ .from(executionWorkspaces)
+ .where(and(eq(executionWorkspaces.id, sel.scopeId), eq(executionWorkspaces.companyId, sel.companyId)))
+ .then((rows) => rows[0] ?? null);
+ return {
+ projectId: row?.projectId ?? null,
+ projectWorkspaceId: row?.projectWorkspaceId ?? null,
+ executionWorkspaceId: sel.scopeId,
+ };
+ }
+ return { projectId: null, projectWorkspaceId: null, executionWorkspaceId: null };
}
function scopeIssueConditions(sel: ResolvedSelector) {
if (sel.scopeKind === "project") return [eq(issues.projectId, sel.scopeId!)];
if (sel.scopeKind === "project_workspace") return [eq(issues.projectWorkspaceId, sel.scopeId!)];
+ if (sel.scopeKind === "execution_workspace") return [eq(issues.executionWorkspaceId, sel.scopeId!)];
return [];
}
@@ -464,7 +497,7 @@ export function summarySlotService(db: Db) {
}
}
- const { projectId, projectWorkspaceId } = await resolveGenerationTargetProject(sel);
+ const { projectId, projectWorkspaceId, executionWorkspaceId } = await resolveGenerationTargetProject(sel);
const scopeSnapshot = await buildScopeSnapshot(sel, existing?.lastGeneratedAt ?? null);
const createdAt = new Date();
const generationVersion = existing?.generatingIssueId ?? existing?.updatedAt.toISOString() ?? "initial";
@@ -472,6 +505,7 @@ export function summarySlotService(db: Db) {
const created = await issuesSvc.create(sel.companyId, {
projectId,
projectWorkspaceId,
+ executionWorkspaceId,
title: generationIssueTitle(sel, createdAt),
description: generationIssueDescription(sel, scopeSnapshot),
status: "todo",
diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx
index faa981a03f7..d4356c0d212 100644
--- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx
+++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx
@@ -288,15 +288,15 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
});
});
- it("shows the linked project workspace summary above tasks", async () => {
+ it("shows a summary scoped to the execution workspace above tasks", async () => {
mockExecutionWorkspacesApi.get.mockResolvedValue(workspace({ projectWorkspaceId: "project-workspace-1" }));
await render();
expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({
companyId: "company-1",
- scopeKind: "project_workspace",
- scopeId: "project-workspace-1",
+ scopeKind: "execution_workspace",
+ scopeId: "workspace-1",
title: "Workspace summary",
}));
const summary = container.querySelector('[data-testid="summary-slot-card"]');
@@ -307,11 +307,15 @@ describe("ExecutionWorkspaceDetail plugin slots", () => {
expect(summary.compareDocumentPosition(issues) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0);
});
- it("does not show a project workspace summary for standalone execution workspaces", async () => {
+ it("shows an isolated summary for standalone execution workspaces", async () => {
await render();
- expect(mockSummarySlotCard).not.toHaveBeenCalled();
- expect(container.querySelector('[data-testid="summary-slot-card"]')).toBeNull();
+ expect(mockSummarySlotCard).toHaveBeenCalledWith(expect.objectContaining({
+ companyId: "company-1",
+ scopeKind: "execution_workspace",
+ scopeId: "workspace-1",
+ }));
+ expect(container.querySelector('[data-testid="summary-slot-card"]')).not.toBeNull();
});
it("does not mount plugin slots scoped to other entity types", async () => {
diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx
index 2cc119a5422..9fc6cf47bbd 100644
--- a/ui/src/pages/ExecutionWorkspaceDetail.tsx
+++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx
@@ -1523,15 +1523,13 @@ export function ExecutionWorkspaceDetail() {
) : activeTab === "issues" ? (
- {workspace.projectWorkspaceId ? (
-
- ) : null}
+
Date: Tue, 11 Aug 2026 09:57:19 -0400
Subject: [PATCH 034/201] fix(skills): refresh project folders in place
(#11066)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip helps operators manage agent skills across a company.
> - The installed skills view groups project-backed skills into folders.
> - The view showed two folder creation controls and only offered a
global project scan.
> - Operators need one clear folder action and a refresh action for the
selected project.
> - This pull request keeps folder creation in the folder rail and adds
a scoped project refresh.
> - The benefit is a calmer skills view and faster, more precise project
skill updates.
## Linked Issues or Issue Description
No public GitHub issue exists for this focused UI bug.
**What happened?**
The installed skills view repeated the folder creation action in the
toolbar. A selected project folder also had no way to refresh only its
own project skills.
**Expected behavior**
The folder rail must own folder creation. A selected project-backed
folder must offer a refresh action that scans only that project and
refreshes the skill and folder queries.
**Steps to reproduce**
1. Open the installed skills view for a company with project-backed
skill folders.
2. Select a project folder.
3. Observe the duplicate folder action and the absence of a
project-scoped refresh action.
**Paperclip version or commit**
Reproduced before this two-commit fix on `master`.
**Deployment mode**
Local development with `pnpm dev`.
## What Changed
- Removed the duplicate toolbar folder creation button when the folder
rail exists.
- Preserved the toolbar folder action when no folder rail exists.
- Added a refresh action beside the breadcrumb for a selected
project-backed folder.
- Passed the selected project ID to the project scan API.
- Refreshed both the installed skill list and skill folder data after
scans.
- Added component tests for compact folder creation, the empty-folder
fallback, and scoped project refresh.
## Verification
- `pnpm exec vitest run ui/src/pages/CompanySkills.test.tsx` — 20 tests
passed.
- `pnpm check:token-gates` — passed with all three gates clean.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm test:run` — the server and UI stages passed 7,475 tests. The CLI
stage then found one environment-sensitive AWS doctor assertion because
this agent runtime injects static AWS credentials.
- `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest
run cli/src/__tests__/secrets.test.ts --project paperclipai` — all 8
tests passed.
- GitHub CI — all latest-head checks passed.
## Risks
- Low risk. The scoped refresh depends on the existing `project:`
folder system key.
- The global scan path is unchanged.
- There are no schema, migration, API contract, dependency, workflow, or
documentation changes.
> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.
## Model Used
OpenAI Codex, GPT-5 family. The runtime did not expose a more specific
model ID or context-window size. The agent used high-reasoning mode,
repository tools, GitHub tools, and code execution.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip
---
ui/src/pages/CompanySkills.test.tsx | 102 ++++++++++++++++++++++++++++
ui/src/pages/CompanySkills.tsx | 50 +++++++++++---
2 files changed, 142 insertions(+), 10 deletions(-)
diff --git a/ui/src/pages/CompanySkills.test.tsx b/ui/src/pages/CompanySkills.test.tsx
index 180fd9d45a8..ef381ae1bcb 100644
--- a/ui/src/pages/CompanySkills.test.tsx
+++ b/ui/src/pages/CompanySkills.test.tsx
@@ -279,6 +279,59 @@ async function renderDiscoveryGrid(props: Partial>;
+}
+
function buttonsNamed(node: ParentNode, name: string) {
return Array.from(node.querySelectorAll("button")).filter((button) => button.textContent?.trim() === name);
}
@@ -341,6 +394,55 @@ describe("DiscoveryGrid Studio entry points", () => {
expect(onCreate).toHaveBeenCalledTimes(2);
});
+ it("keeps folder creation in the compact rail control", async () => {
+ const props = projectFolderGridProps();
+ const node = await renderDiscoveryGrid(props);
+ const compactCreateButton = node.querySelector('button[title="New folder"]');
+
+ expect(buttonsNamed(node, "New folder")).toHaveLength(0);
+ expect(compactCreateButton).not.toBeNull();
+
+ await click(compactCreateButton!);
+
+ expect(props.onCreateFolderIn).toHaveBeenCalledWith(null);
+ expect(props.onCreateFolder).not.toHaveBeenCalled();
+ });
+
+ it("keeps folder creation available when no folder rail exists", async () => {
+ const onCreateFolder = vi.fn();
+ const node = await renderDiscoveryGrid({
+ ...projectFolderGridProps(),
+ folderResult: { ...projectFolderResult, folders: [] },
+ onCreateFolder,
+ });
+ const createButton = buttonsNamed(node, "New folder")[0] as HTMLButtonElement;
+
+ expect(createButton).toBeDefined();
+
+ await click(createButton);
+
+ expect(onCreateFolder).toHaveBeenCalledOnce();
+ });
+
+ it("refreshes only the project represented by the active project folder", async () => {
+ const onScan = vi.fn();
+ const node = await renderDiscoveryGrid({
+ ...projectFolderGridProps(),
+ folderSelection: "project-folder",
+ onScan,
+ });
+ const refreshButton = node.querySelector(
+ 'button[aria-label="Refresh Acme project skills"]',
+ );
+
+ expect(refreshButton).not.toBeNull();
+
+ await click(refreshButton!);
+
+ expect(onScan).toHaveBeenCalledOnce();
+ expect(onScan).toHaveBeenCalledWith("project-1");
+ });
+
it("does not open a skill when keyboard-activating its actions button", async () => {
const onOpenCard = vi.fn();
const card = {
diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx
index 2bb0859f07f..42a9325d464 100644
--- a/ui/src/pages/CompanySkills.tsx
+++ b/ui/src/pages/CompanySkills.tsx
@@ -1084,7 +1084,7 @@ export function DiscoveryGrid({
onImport: () => void;
onImportFromProject: () => void;
onBrowseCatalog: () => void;
- onScan: () => void;
+ onScan: (projectId?: string) => void;
scanPending: boolean;
scanStatus: string | null;
folderResult?: FolderListResult | null;
@@ -1138,7 +1138,15 @@ export function DiscoveryGrid({
);
// The nested folder tree owns the left rail whenever folders (reserved roots
// or user folders) exist for the installed view.
- const showFolderRail = Boolean(folderResult && folderResult.folders.length > 0 && onFolderSelect && folderActionsReady);
+ const showFolderRail = Boolean(
+ folderResult && folderResult.folders.length > 0 && onFolderSelect && folderActionsReady,
+ );
+ const activeProjectFolder = useMemo(() => {
+ if (!folderResult || folderSelection === "all" || folderSelection === "unfiled") return null;
+ const folder = folderResult.folders.find((candidate) => candidate.id === folderSelection);
+ return folder?.systemKey?.startsWith("project:") ? folder : null;
+ }, [folderResult, folderSelection]);
+ const activeProjectId = activeProjectFolder?.systemKey?.slice("project:".length) || null;
return (
// On desktop the store is bounded to the viewport so the category sidebar
@@ -1240,8 +1248,9 @@ export function DiscoveryGrid({
onScan()}
disabled={scanPending}
+ aria-label="Scan project workspaces for skills"
title="Scan project workspaces for skills"
>
@@ -1289,7 +1298,7 @@ export function DiscoveryGrid({
/>
) : null}
- {onCreateFolder ? (
+ {onCreateFolder && !showFolderRail ? (
New folder
@@ -1357,8 +1366,21 @@ export function DiscoveryGrid({
{scanStatus ?
{scanStatus}
: null}
{showFolderRail && onFolderSelect ? (
-
+
+ {activeProjectFolder && activeProjectId ? (
+ onScan(activeProjectId)}
+ disabled={scanPending}
+ aria-label={`Refresh ${activeProjectFolder.name} project skills`}
+ title={`Refresh skills from ${activeProjectFolder.name}`}
+ >
+
+ Refresh
+
+ ) : null}
) : null}
{folderNudgeStorageKey && onCreateFolder && folderResult && folderResult.folders.length === 0 && !loading && cards.length > 0 ? (
@@ -4278,13 +4300,21 @@ export function CompanySkills() {
});
const scanProjects = useMutation({
- mutationFn: () => companySkillsApi.scanProjects(selectedCompanyId!),
- onMutate: () => {
- setScanStatusMessage("Scanning project workspaces for skills...");
+ mutationFn: (projectId?: string) => companySkillsApi.scanProjects(
+ selectedCompanyId!,
+ projectId ? { projectIds: [projectId] } : {},
+ ),
+ onMutate: (projectId) => {
+ setScanStatusMessage(
+ projectId ? "Refreshing project skills..." : "Scanning project workspaces for skills...",
+ );
},
onSuccess: async (result) => {
setScanStatusMessage("Refreshing skills list...");
- await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) });
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(selectedCompanyId!) }),
+ queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "skill") }),
+ ]);
const summary = formatProjectScanSummary(result);
setScanStatusMessage(summary);
pushToast({
@@ -5308,7 +5338,7 @@ export function CompanySkills() {
onImport={() => setImportDialogOpen(true)}
onImportFromProject={() => setImportFromProjectOpen(true)}
onBrowseCatalog={() => setDiscoveryTab("catalog")}
- onScan={() => scanProjects.mutate()}
+ onScan={(projectId) => scanProjects.mutate(projectId)}
scanPending={scanProjects.isPending}
scanStatus={scanStatusMessage}
folderResult={showInstalledFolders ? railSkillFolderResult : null}
From 1ea2f0e2d6fab549fffbfa7e50d72717140c8cd5 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 11 Aug 2026 08:47:32 -0700
Subject: [PATCH 035/201] feat(cli): add 'paperclipai channels' to show release
lanes and the current one (#11210)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release channels (canary → nightly → beta → stable) select by
install target, and users discover them today only through
maintainer-oriented docs
> - A user who wants to know "which lane am I on, and what else is
there" has no self-serve answer
> - The channel rollout planned a read-only CLI command for exactly this
> - This pull request adds `paperclipai channels`: every lane with the
version its dist-tag resolves to, the install command for it, and which
lane the running install follows
> - The benefit is self-serve lane discovery without reading release
documentation
## Linked Issues or Issue Description
Refs #11008 — the user-facing discovery surface for the channel model
completed there.
**Subsystem affected**
CLI: `cli/src/commands/channels.ts` (new), `cli/src/index.ts`,
`doc/CHANNELS.md`, tests.
**Problem or motivation**
Channel selection is install-based (`@latest` / `@beta` / `@nightly` /
`@canary`), but nothing in the product tells a user which channel their
install follows or what the other lanes currently resolve to. The
information lives in `doc/CHANNELS.md` and the npm registry, neither of
which a running install surfaces.
**Proposed solution**
A read-only `paperclipai channels` command: prints each channel with the
version its dist-tag currently resolves to (per-lane registry lookups
that degrade to `unavailable` individually), the install command for
each, and the running install's lane parsed from its version suffix —
source checkouts carry the repository's placeholder version and are
reported as unmapped rather than guessed. `--json` emits the same data
for scripting.
## What Changed
- `cli/src/commands/channels.ts` (new): channel table, lane parsing,
registry resolution, human and `--json` output
- `cli/src/index.ts`: registers `channels`
- `doc/CHANNELS.md`: "Seeing where you are" section
- `cli/src/__tests__/channels.test.ts` (new): lane parsing including
unknown versions, full resolution against a fake runner, per-lane
degradation, table/dist-tag sync
## Verification
- `vitest run cli/src/__tests__/channels.test.ts`: 5 pass
- `pnpm typecheck` in `cli/`
- Live run against the real registry shows all four lanes with their
current versions (`2026.722.0` / `2026.811.0-beta.0` /
`2026.811.0-nightly.0` / canary) and correctly reports a source checkout
as unmapped
## Risks
- Low. Read-only command reusing the existing `resolvePublishedVersion`
registry helper; no state, no auth, no publish surface
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use. All changes model-authored under
human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
cli/src/__tests__/channels.test.ts | 81 ++++++++++++++++++++
cli/src/commands/channels.ts | 119 +++++++++++++++++++++++++++++
cli/src/index.ts | 9 +++
doc/CHANNELS.md | 10 +++
4 files changed, 219 insertions(+)
create mode 100644 cli/src/__tests__/channels.test.ts
create mode 100644 cli/src/commands/channels.ts
diff --git a/cli/src/__tests__/channels.test.ts b/cli/src/__tests__/channels.test.ts
new file mode 100644
index 00000000000..97c0ad62220
--- /dev/null
+++ b/cli/src/__tests__/channels.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import {
+ channelForVersion,
+ collectChannelState,
+ RELEASE_CHANNELS,
+} from "../commands/channels.js";
+import type { CommandRunner } from "../commands/install.js";
+
+describe("channelForVersion", () => {
+ it("maps published versions to their lane", () => {
+ expect(channelForVersion("2026.811.0")).toBe("stable");
+ expect(channelForVersion("2026.811.0-beta.0")).toBe("beta");
+ expect(channelForVersion("2026.811.0-nightly.0")).toBe("nightly");
+ expect(channelForVersion("2026.811.0-canary.3")).toBe("canary");
+ });
+
+ it("treats non-CalVer versions as unknown instead of guessing", () => {
+ expect(channelForVersion("0.3.1")).toBe("unknown");
+ expect(channelForVersion("2026.811.0-rc.1")).toBe("unknown");
+ expect(channelForVersion("garbage")).toBe("unknown");
+ });
+});
+
+describe("collectChannelState", () => {
+ const versionsByTag: Record = {
+ latest: "2026.722.0",
+ beta: "2026.811.0-beta.0",
+ nightly: "2026.811.0-nightly.0",
+ canary: "2026.811.0-canary.3",
+ };
+
+ const fakeRunner: CommandRunner = async (_command, args) => {
+ const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
+ const tag = spec?.slice("paperclipai@".length) ?? "";
+ const version = versionsByTag[tag];
+ if (!version) throw new Error(`unexpected dist-tag: ${tag}`);
+ return { stdout: JSON.stringify(version), stderr: "" };
+ };
+
+ it("resolves every channel's dist-tag from the registry", async () => {
+ const state = await collectChannelState(fakeRunner);
+
+ expect(state.map((entry) => entry.channel)).toEqual([
+ "stable",
+ "beta",
+ "nightly",
+ "canary",
+ ]);
+ expect(state.map((entry) => entry.version)).toEqual([
+ "2026.722.0",
+ "2026.811.0-beta.0",
+ "2026.811.0-nightly.0",
+ "2026.811.0-canary.3",
+ ]);
+ });
+
+ it("degrades a single unavailable channel to null without failing the rest", async () => {
+ const flakyRunner: CommandRunner = async (command, args, options) => {
+ const spec = (args ?? []).find((arg) => arg.startsWith("paperclipai@"));
+ if (spec === "paperclipai@nightly") throw new Error("registry timeout");
+ return fakeRunner(command, args, options);
+ };
+
+ const state = await collectChannelState(flakyRunner);
+ const byChannel = Object.fromEntries(state.map((entry) => [entry.channel, entry.version]));
+
+ expect(byChannel.nightly).toBeNull();
+ expect(byChannel.stable).toBe("2026.722.0");
+ expect(byChannel.beta).toBe("2026.811.0-beta.0");
+ expect(byChannel.canary).toBe("2026.811.0-canary.3");
+ });
+
+ it("keeps the channel table and dist-tags in sync", () => {
+ expect(RELEASE_CHANNELS.map((entry) => entry.distTag)).toEqual([
+ "latest",
+ "beta",
+ "nightly",
+ "canary",
+ ]);
+ });
+});
diff --git a/cli/src/commands/channels.ts b/cli/src/commands/channels.ts
new file mode 100644
index 00000000000..3fe211b4957
--- /dev/null
+++ b/cli/src/commands/channels.ts
@@ -0,0 +1,119 @@
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+import pc from "picocolors";
+import { resolvePublishedVersion, type CommandRunner } from "./install.js";
+import { packageVersion } from "../version.js";
+
+const execFileAsync = promisify(execFile);
+
+const defaultRunCommand: CommandRunner = (command, args, options) =>
+ execFileAsync(command, args, { ...options, encoding: "utf8" });
+
+export type ChannelName = "stable" | "beta" | "nightly" | "canary";
+
+export type ChannelDescriptor = {
+ channel: ChannelName;
+ distTag: string;
+ cadence: string;
+ audience: string;
+};
+
+// Ordered from most to least stable — the order users should consider them.
+export const RELEASE_CHANNELS: readonly ChannelDescriptor[] = [
+ {
+ channel: "stable",
+ distTag: "latest",
+ cadence: "manual, soaked in beta for 3+ days",
+ audience: "the recommended release for almost everyone",
+ },
+ {
+ channel: "beta",
+ distTag: "beta",
+ cadence: "manual promotion behind an approval gate",
+ audience: "release candidates: what stable becomes a few days later",
+ },
+ {
+ channel: "nightly",
+ distTag: "nightly",
+ cadence: "once a night, smoke-gated",
+ audience: "yesterday's merges, tested as a unit",
+ },
+ {
+ channel: "canary",
+ distTag: "canary",
+ cadence: "every merge to master",
+ audience: "the bleeding edge",
+ },
+];
+
+const CALVER_RE = /^\d{4}\.\d{1,4}\.\d+$/;
+const PRERELEASE_RE = /^\d{4}\.\d{1,4}\.\d+-(canary|nightly|beta)\.\d+$/;
+
+// Published versions carry their lane in the version string; the source
+// checkout's package.json holds a placeholder that matches neither form.
+export function channelForVersion(version: string): ChannelName | "unknown" {
+ const prerelease = version.match(PRERELEASE_RE);
+ if (prerelease) return prerelease[1] as ChannelName;
+ if (CALVER_RE.test(version)) return "stable";
+ return "unknown";
+}
+
+export type ChannelState = ChannelDescriptor & { version: string | null };
+
+export async function collectChannelState(
+ runCommand: CommandRunner = defaultRunCommand,
+): Promise {
+ const resolved = await Promise.allSettled(
+ RELEASE_CHANNELS.map((entry) => resolvePublishedVersion(entry.distTag, runCommand)),
+ );
+ return RELEASE_CHANNELS.map((entry, index) => {
+ const outcome = resolved[index];
+ return {
+ ...entry,
+ version: outcome.status === "fulfilled" ? outcome.value : null,
+ };
+ });
+}
+
+export type ChannelsOptions = { json?: boolean };
+
+export async function channelsCommand(
+ options: ChannelsOptions = {},
+ runCommand: CommandRunner = defaultRunCommand,
+): Promise {
+ const state = await collectChannelState(runCommand);
+ const currentChannel = channelForVersion(packageVersion);
+
+ if (options.json) {
+ console.log(
+ JSON.stringify(
+ {
+ current: { version: packageVersion, channel: currentChannel },
+ channels: state,
+ },
+ null,
+ 2,
+ ),
+ );
+ return;
+ }
+
+ console.log(pc.bold("Paperclip release channels"));
+ console.log("");
+ for (const entry of state) {
+ const version = entry.version ?? pc.yellow("unavailable");
+ console.log(` ${pc.bold(entry.channel.padEnd(8))} ${version}`);
+ console.log(` ${" ".repeat(8)} ${pc.dim(`${entry.cadence} — ${entry.audience}`)}`);
+ console.log(` ${" ".repeat(8)} ${pc.dim(`npx paperclipai@${entry.distTag} onboard`)}`);
+ console.log("");
+ }
+
+ if (currentChannel === "unknown") {
+ console.log(
+ `This install reports version ${pc.bold(packageVersion)}, which does not map to a published channel (source checkouts report the repository placeholder).`,
+ );
+ } else {
+ console.log(`This install is version ${pc.bold(packageVersion)} on the ${pc.bold(currentChannel)} channel.`);
+ }
+ console.log(`Docker images use the same names: ghcr.io/paperclipai/paperclip:{latest,beta,nightly,canary}`);
+}
diff --git a/cli/src/index.ts b/cli/src/index.ts
index fd8d1ee4f77..b26b5cbe187 100644
--- a/cli/src/index.ts
+++ b/cli/src/index.ts
@@ -2,6 +2,7 @@ import { Command } from "commander";
import { onboard } from "./commands/onboard.js";
import { doctor } from "./commands/doctor.js";
import { envCommand } from "./commands/env.js";
+import { channelsCommand } from "./commands/channels.js";
import { configure } from "./commands/configure.js";
import { addAllowedHostname } from "./commands/allowed-hostname.js";
import { heartbeatRun } from "./commands/heartbeat-run.js";
@@ -130,6 +131,14 @@ program
.option("-d, --data-dir ", DATA_DIR_OPTION_HELP)
.action(envCommand);
+program
+ .command("channels")
+ .description("Show the release channels and which one this install follows")
+ .option("--json", "Machine-readable output")
+ .action(async (opts) => {
+ await channelsCommand(opts);
+ });
+
program
.command("configure")
.description("Update configuration sections")
diff --git a/doc/CHANNELS.md b/doc/CHANNELS.md
index b99ae33a3d8..f809a386fc4 100644
--- a/doc/CHANNELS.md
+++ b/doc/CHANNELS.md
@@ -56,6 +56,16 @@ docker pull ghcr.io/paperclipai/paperclip:canary
Every image is also published as `:sha-` for exact pinning, and
stable images additionally get `:YYYY.MDD.P` version tags.
+## Seeing where you are
+
+```bash
+npx paperclipai channels
+```
+
+prints every channel with the version it currently resolves to, the install
+command for each, and which channel your install follows (with `--json` for
+scripting).
+
## Switching channels
Channel choice is per-install: install from a different tag and you're on that
From 2da6a248c3e0cd7d5afbf3590b7bfa00df6bb297 Mon Sep 17 00:00:00 2001
From: Devin Foley
Date: Tue, 11 Aug 2026 08:48:13 -0700
Subject: [PATCH 036/201] fix(release): surface recovery commands when a lane
tag push is rejected (#11208)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - The release subsystem's promotion lanes publish to npm, then push a
lane tag and dispatch the Docker image build at that tag
> - The first nightly of the beta-tooling merge published to npm and
then died at the tag push: GITHUB_TOKEN may not create refs pointing at
workflow-modifying commits from dispatch or scheduled runs
> - The failure was a bare `remote rejected` with no guidance, leaving
the release half-finished (npm live, no tag, no images) until an
operator reverse-engineered the recovery
> - This pull request makes every lane's tag push degrade into exact
recovery instructions in the job summary
> - The benefit is that a rare platform-permission rejection becomes a
two-minute runbook operation instead of a forensic exercise
## Linked Issues or Issue Description
Refs #11008 — the incident occurred promoting that change's own merge
commit, the first workflow-modifying commit to flow through the lanes it
introduced.
**Subsystem affected**
Release automation: `.github/workflows/release.yml`, `doc/RELEASING.md`,
workflow wiring tests.
**Problem or motivation**
Run 31445344811 published `2026.811.0-nightly.0` to npm, then failed
pushing `nightly/v2026.811.0-nightly.0`: `refusing to allow a GitHub App
to create or update workflow .github/workflows/release.yml without
workflows permission`. The tagged commit modifies workflow files, and
GITHUB_TOKEN may not create refs pointing at such commits from dispatch
or scheduled runs (push-event runs are exempt, which is why the canary
tag on the same commit succeeded). The job failed with no explanation
and the Docker dispatch never ran.
**Proposed solution**
Wrap the nightly, beta, and stable tag pushes: on rejection, write the
exact recovery commands into the job summary — create and push the tag
with maintainer credentials, dispatch `docker.yml` at the tag, and for
stable also run `create-github-release.sh` — then fail the job. Document
the cause and recovery in the failure playbooks and pin the three
recovery blocks with a wiring test.
## What Changed
- `.github/workflows/release.yml`: recovery-summary wrappers on the
nightly, beta, and stable tag-push steps
- `doc/RELEASING.md`: failure-playbook entry for the
workflows-permission rejection
- `scripts/__tests__/release-verify-workflow.test.mjs`: wiring test
asserting all three lanes carry the recovery summary
## Verification
- Wiring tests: 6 pass; YAML parse of the workflow
- The recovery commands are exactly the ones used to resolve the real
incident (tag push + `docker.yml` dispatch for
`nightly/v2026.811.0-nightly.0`)
## Risks
- Low. The happy path is unchanged (a successful push skips the
wrapper); the failure path trades a bare error for actionable output and
still fails the job, since the release state is genuinely incomplete
## Model Used
Claude Fable 5 (`claude-fable-5`, Anthropic) in Claude Code, with
extended thinking and full tool use. All changes model-authored under
human direction.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green (pending — will confirm before
merge)
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(pending — will confirm before merge)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---
.github/workflows/release.yml | 61 ++++++++++++++++++-
doc/RELEASING.md | 11 ++++
.../release-verify-workflow.test.mjs | 10 +++
3 files changed, 79 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c8e74ea146f..3e07b363723 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -307,7 +307,25 @@ jobs:
echo "Error: no nightly tag points at HEAD after release." >&2
exit 1
fi
- git push origin "refs/tags/${tag}"
+ if ! git push origin "refs/tags/${tag}"; then
+ sha="$(git rev-parse HEAD)"
+ {
+ echo "## Tag push rejected"
+ echo ""
+ echo "The npm publish succeeded, but pushing \`${tag}\` was rejected."
+ echo "This usually means the tagged commit modifies workflow files,"
+ echo "which GITHUB_TOKEN may not reference when creating refs from"
+ echo "dispatch or scheduled runs. Recover with maintainer credentials:"
+ echo ""
+ echo '```'
+ echo "git tag ${tag} ${sha}"
+ echo "git push origin refs/tags/${tag}"
+ echo "gh workflow run docker.yml --ref refs/tags/${tag}"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "::error::Tag push rejected; see the job summary for recovery commands." >&2
+ exit 1
+ fi
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
# trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
@@ -485,7 +503,25 @@ jobs:
echo "Error: no beta tag points at HEAD after release." >&2
exit 1
fi
- git push origin "refs/tags/${tag}"
+ if ! git push origin "refs/tags/${tag}"; then
+ sha="$(git rev-parse HEAD)"
+ {
+ echo "## Tag push rejected"
+ echo ""
+ echo "The npm publish succeeded, but pushing \`${tag}\` was rejected."
+ echo "This usually means the tagged commit modifies workflow files,"
+ echo "which GITHUB_TOKEN may not reference when creating refs from"
+ echo "dispatch or scheduled runs. Recover with maintainer credentials:"
+ echo ""
+ echo '```'
+ echo "git tag ${tag} ${sha}"
+ echo "git push origin refs/tags/${tag}"
+ echo "gh workflow run docker.yml --ref refs/tags/${tag}"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "::error::Tag push rejected; see the job summary for recovery commands." >&2
+ exit 1
+ fi
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
# trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
@@ -743,7 +779,26 @@ jobs:
echo "Error: no stable tag points at HEAD after release." >&2
exit 1
fi
- git push origin "refs/tags/${tag}"
+ if ! git push origin "refs/tags/${tag}"; then
+ sha="$(git rev-parse HEAD)"
+ {
+ echo "## Tag push rejected"
+ echo ""
+ echo "The npm publish succeeded, but pushing \`${tag}\` was rejected."
+ echo "This usually means the tagged commit modifies workflow files,"
+ echo "which GITHUB_TOKEN may not reference when creating refs from"
+ echo "dispatch or scheduled runs. Recover with maintainer credentials:"
+ echo ""
+ echo '```'
+ echo "git tag ${tag} ${sha}"
+ echo "git push origin refs/tags/${tag}"
+ echo "gh workflow run docker.yml --ref refs/tags/${tag}"
+ echo "./scripts/create-github-release.sh ${tag#v}"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ echo "::error::Tag push rejected; see the job summary for recovery commands." >&2
+ exit 1
+ fi
# Tag pushes made with GITHUB_TOKEN do not fire docker.yml's tag
# trigger (GitHub suppresses workflow runs caused by GITHUB_TOKEN
diff --git a/doc/RELEASING.md b/doc/RELEASING.md
index a77ca18729f..f6f3e6d41c3 100644
--- a/doc/RELEASING.md
+++ b/doc/RELEASING.md
@@ -380,6 +380,17 @@ force one: dispatch `release.yml` with `channel: nightly` (optionally pinning
If the nightly published to npm but the tag push or Docker dispatch failed,
push the `nightly/v*` tag manually and run `docker.yml` at that tag.
+### If a tag push is rejected with a workflows-permission error
+
+GITHUB_TOKEN may not create refs that point at commits which modify workflow
+files when the run was started by dispatch or schedule (push-triggered runs
+are exempt, which is why canary tags on the same commit succeed). The npm
+publish is already complete and correct when this happens. The failed job's
+summary contains the exact recovery commands: create and push the tag with
+maintainer credentials, then dispatch `docker.yml` at the tag (and for
+stable, run `create-github-release.sh`). This only occurs when a
+release-infrastructure commit itself becomes a promotion source.
+
### If a beta looks bad during soak
Do not promote it to stable. Fix forward: land the fix on `master`, let it
diff --git a/scripts/__tests__/release-verify-workflow.test.mjs b/scripts/__tests__/release-verify-workflow.test.mjs
index 02de233cb67..df30f9e9534 100644
--- a/scripts/__tests__/release-verify-workflow.test.mjs
+++ b/scripts/__tests__/release-verify-workflow.test.mjs
@@ -47,6 +47,16 @@ test("promotion selection guards against sources that predate their channel tool
assert.match(releaseWorkflow, /git show "\$\{sha\}:scripts\/release\.sh" \| grep -qF 'canary\|nightly\|beta\|stable\)'/);
});
+test("every lane's tag push degrades to recovery instructions when rejected", () => {
+ const releaseWorkflow = readWorkflow("release.yml");
+
+ // GITHUB_TOKEN may not create refs pointing at workflow-modifying commits
+ // from dispatch or scheduled runs; a rejected tag push after a successful
+ // npm publish must surface runbook recovery commands, not a bare error.
+ const occurrences = releaseWorkflow.match(/## Tag push rejected/g) ?? [];
+ assert.equal(occurrences.length, 3, "nightly, beta, and stable each carry the recovery summary");
+});
+
test("release smoke workflow extends the container readiness budget for CI", () => {
const smokeWorkflow = readWorkflow("release-smoke.yml");
const harness = readFileSync(path.join(repoRoot, "scripts/docker-onboard-smoke.sh"), "utf8");
From 815e49bb7c005ebfe4b4c356b64de53cb074c3ad Mon Sep 17 00:00:00 2001
From: scotttong
Date: Tue, 11 Aug 2026 09:06:21 -0700
Subject: [PATCH 037/201] feat: make chat-style tasks the default experience
(#11101)
---
.../adapter-utils/src/server-utils.test.ts | 3 +
packages/adapter-utils/src/server-utils.ts | 1 +
.../claude-local/src/server/acp.test.ts | 9 +-
.../0212_onboarding_first_task_unique.sql | 3 +
packages/db/src/migrations/meta/_journal.json | 7 +
packages/db/src/schema/issues.ts | 7 +
packages/shared/src/constants.ts | 5 +
packages/shared/src/feature-catalog.ts | 6 +-
packages/shared/src/index.ts | 1 +
packages/shared/src/types/instance.ts | 2 +-
packages/shared/src/types/issue.ts | 13 +-
packages/shared/src/validators/instance.ts | 2 +-
packages/shared/src/validators/issue.ts | 35 +-
server/src/__tests__/built-in-agents.test.ts | 56 +-
.../src/__tests__/companies-service.test.ts | 29 +-
.../instance-settings-service.test.ts | 16 +-
...issue-onboarding-first-task-routes.test.ts | 168 ++++
.../issue-thread-interactions-service.test.ts | 185 +++++
server/src/routes/issues.ts | 110 ++-
server/src/services/built-in-agents.ts | 16 +
server/src/services/instance-settings.ts | 4 +-
.../src/services/issue-thread-interactions.ts | 30 +-
.../src/services/onboarding-greeting.test.ts | 43 ++
server/src/services/onboarding-greeting.ts | 39 +
.../e2e/conference-room-typing-intro.spec.ts | 228 +++---
tests/e2e/helpers/onboarding-landing.ts | 80 ++
tests/e2e/nux-phase4-screenshots.spec.ts | 4 +-
tests/e2e/onboarding.spec.ts | 6 +-
.../planning-mode-visual-verification.spec.ts | 31 +-
ui/src/App.tsx | 9 +-
ui/src/components/IssueChatThread.tsx | 19 +-
ui/src/components/IssueProperties.test.tsx | 2 +-
.../IssueThreadInteractionCard.test.tsx | 191 ++++-
.../components/IssueThreadInteractionCard.tsx | 731 ++++++++++--------
ui/src/components/OnboardingWizard.tsx | 121 +--
ui/src/components/PropertiesPanel.test.tsx | 8 +-
ui/src/components/PropertiesPanel.tsx | 10 +-
ui/src/components/SidebarCompanyMenu.test.tsx | 10 +-
ui/src/components/SidebarCompanyMenu.tsx | 23 +-
.../components/TaskChatRedesignGate.test.tsx | 95 ---
ui/src/components/TaskChatRedesignGate.tsx | 18 -
ui/src/components/TaskChatThread.test.tsx | 115 ++-
ui/src/components/TaskChatThread.tsx | 254 ++++--
.../IssuePlanConfirmationActionBar.test.tsx | 117 ---
.../IssuePlanConfirmationActionBar.tsx | 216 ------
.../issue-properties/IssueProperties.tsx | 44 +-
.../IssuePropertiesArtifactsTab.tsx | 2 +-
.../IssuePropertiesPlansTab.tsx | 59 +-
.../task-chat/TaskChatBubbleActions.tsx | 34 +-
.../TaskChatInteractionCard.test.tsx | 12 +-
.../task-chat/TaskChatLiveRunPill.test.tsx | 92 +++
.../task-chat/TaskChatLiveRunPill.tsx | 88 +++
.../task-chat/TaskChatLiveTail.test.tsx | 138 ++++
.../components/task-chat/TaskChatLiveTail.tsx | 86 +++
.../task-chat/TaskChatThreadView.tsx | 13 +-
.../interaction-thread-order.test.ts | 125 +++
.../task-chat/interaction-thread-order.ts | 79 ++
.../components/task-chat/task-chat-model.ts | 4 +-
.../components/task-chat/task-chat-states.ts | 4 +-
.../transcript/RunTranscriptView.test.tsx | 46 +-
.../transcript/RunTranscriptView.tsx | 83 +-
.../transcript/useLiveRunTranscripts.test.tsx | 108 +++
.../transcript/useLiveRunTranscripts.ts | 99 ++-
.../issueThreadInteractionFixtures.ts | 47 +-
...d.ts => useClassicTaskInterfaceEnabled.ts} | 19 +-
ui/src/index.css | 35 +-
ui/src/lib/ceo-instructions.ts | 2 +-
ui/src/lib/issue-chat-messages.test.ts | 70 ++
ui/src/lib/issue-chat-messages.ts | 6 +
ui/src/lib/issue-thread-interactions.test.ts | 218 +++++-
ui/src/lib/issue-thread-interactions.ts | 78 ++
ui/src/lib/onboarding-launch.test.ts | 2 +
ui/src/lib/onboarding-launch.ts | 3 +
ui/src/lib/run-log-chunks.test.ts | 78 ++
ui/src/lib/run-log-chunks.ts | 124 ++-
.../InstanceExperimentalSettings.test.tsx | 24 +-
ui/src/pages/InstanceExperimentalSettings.tsx | 16 +-
ui/src/pages/IssueDetail.test.tsx | 341 ++++----
ui/src/pages/IssueDetail.tsx | 94 ++-
ui/src/pages/TaskChatLab.tsx | 4 +-
80 files changed, 3858 insertions(+), 1497 deletions(-)
create mode 100644 packages/db/src/migrations/0212_onboarding_first_task_unique.sql
create mode 100644 server/src/__tests__/issue-onboarding-first-task-routes.test.ts
create mode 100644 server/src/services/onboarding-greeting.test.ts
create mode 100644 server/src/services/onboarding-greeting.ts
create mode 100644 tests/e2e/helpers/onboarding-landing.ts
delete mode 100644 ui/src/components/TaskChatRedesignGate.test.tsx
delete mode 100644 ui/src/components/TaskChatRedesignGate.tsx
delete mode 100644 ui/src/components/issue-properties/IssuePlanConfirmationActionBar.test.tsx
delete mode 100644 ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx
create mode 100644 ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx
create mode 100644 ui/src/components/task-chat/TaskChatLiveRunPill.tsx
create mode 100644 ui/src/components/task-chat/TaskChatLiveTail.test.tsx
create mode 100644 ui/src/components/task-chat/TaskChatLiveTail.tsx
create mode 100644 ui/src/components/task-chat/interaction-thread-order.test.ts
create mode 100644 ui/src/components/task-chat/interaction-thread-order.ts
rename ui/src/hooks/{useTaskChatRedesignEnabled.ts => useClassicTaskInterfaceEnabled.ts} (62%)
diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts
index 2dd38ccdf41..81ec8458b51 100644
--- a/packages/adapter-utils/src/server-utils.test.ts
+++ b/packages/adapter-utils/src/server-utils.test.ts
@@ -887,6 +887,9 @@ describe("renderPaperclipWakePrompt", () => {
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).not.toContain(
"for request_confirmation this resumes only after acceptance",
);
+ expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
+ "Never create probe or throwaway issue-thread interactions to discover the interactions API shape or your permissions",
+ );
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("confirmation:{issueId}:plan:{revisionId}");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Wait for acceptance before creating implementation subtasks");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts
index 6c51a3cd322..7f4cf5cd70e 100644
--- a/packages/adapter-utils/src/server-utils.ts
+++ b/packages/adapter-utils/src/server-utils.ts
@@ -170,6 +170,7 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.",
"- Use `PAPERCLIP_SCRATCH_DIR` / `PAPERCLIP_RUN_SCRATCH_DIR` for temporary scratch files instead of ad hoc `/tmp` paths; Paperclip removes that run-owned directory after the run ends.",
"- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response (it wakes on acceptance and rejection alike; only expiry does not wake); use wake_assignee_on_accept when you want to resume only after acceptance.",
+ "- Never create probe or throwaway issue-thread interactions to discover the interactions API shape or your permissions; schema discovery goes through the OpenAPI spec and explicit validation errors, not placeholder cards. Every ask_user_questions, suggest_tasks, or request_confirmation you post must carry a real, answerable prompt; withdraw one you no longer need instead of leaving it pending.",
"- When you intentionally restart follow-up work on a completed assigned issue, include structured `resume: true` with the POST /api/issues/{issueId}/comments or PATCH /api/issues/{issueId} comment payload. Generic agent comments on closed issues are inert by default.",
"- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.",
"- If blocked, mark the issue blocked and name the unblock owner and action.",
diff --git a/packages/adapters/claude-local/src/server/acp.test.ts b/packages/adapters/claude-local/src/server/acp.test.ts
index cf988297a9f..45acb00db04 100644
--- a/packages/adapters/claude-local/src/server/acp.test.ts
+++ b/packages/adapters/claude-local/src/server/acp.test.ts
@@ -85,7 +85,14 @@ afterEach(async () => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
- await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
+ // The sandbox process-session bridge writes event files asynchronously; on slow
+ // CI shards a final write can race the recursive rm (ENOTEMPTY on the events
+ // dir), so let fs.rm retry until the writer has quiesced.
+ await Promise.all(
+ tempRoots
+ .splice(0)
+ .map((root) => fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })),
+ );
});
class FakeRuntime {
diff --git a/packages/db/src/migrations/0212_onboarding_first_task_unique.sql b/packages/db/src/migrations/0212_onboarding_first_task_unique.sql
new file mode 100644
index 00000000000..405c57c9e0e
--- /dev/null
+++ b/packages/db/src/migrations/0212_onboarding_first_task_unique.sql
@@ -0,0 +1,3 @@
+CREATE UNIQUE INDEX IF NOT EXISTS "issues_onboarding_first_task_uq"
+ ON "issues" USING btree ("company_id")
+ WHERE "origin_kind" = 'onboarding_first_task';
diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json
index ed4a7ade926..395fb9a1ff4 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -1471,6 +1471,13 @@
"when": 1786129601533,
"tag": "0211_bright_morg",
"breakpoints": true
+ },
+ {
+ "idx": 212,
+ "version": "7",
+ "when": 1786388759523,
+ "tag": "0212_onboarding_first_task_unique",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/src/schema/issues.ts b/packages/db/src/schema/issues.ts
index ae40befba92..55b3ed34931 100644
--- a/packages/db/src/schema/issues.ts
+++ b/packages/db/src/schema/issues.ts
@@ -168,5 +168,12 @@ export const issues = pgTable(
and ${table.hiddenAt} is null
and ${table.status} not in ('done', 'cancelled')`,
),
+ // The onboarding first-task origin grants privileged behavior (agent-attributed
+ // greeting, description suppression), so at most one issue per company may ever
+ // carry it — concurrent creates race on the pre-insert count check and this
+ // index is what atomically rejects the loser.
+ onboardingFirstTaskIdx: uniqueIndex("issues_onboarding_first_task_uq")
+ .on(table.companyId)
+ .where(sql`${table.originKind} = 'onboarding_first_task'`),
}),
);
diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts
index 75c16a15360..bcce7f48db3 100644
--- a/packages/shared/src/constants.ts
+++ b/packages/shared/src/constants.ts
@@ -300,6 +300,10 @@ export type IssueThreadInteractionContinuationPolicy =
export const TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND = "task_watchdog_product_bug";
+// Marks the single onboarding "first task" so surfaces can special-case it
+// (e.g. suppress the seeded-description bubble and rely on a seeded greeting).
+export const ONBOARDING_FIRST_TASK_ORIGIN_KIND = "onboarding_first_task";
+
export const ISSUE_ORIGIN_KINDS = [
"manual",
"routine_execution",
@@ -309,6 +313,7 @@ export const ISSUE_ORIGIN_KINDS = [
"stranded_issue_recovery",
"task_watchdog",
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
+ ONBOARDING_FIRST_TASK_ORIGIN_KIND,
] as const;
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts
index 9916d916f69..986b1f69228 100644
--- a/packages/shared/src/feature-catalog.ts
+++ b/packages/shared/src/feature-catalog.ts
@@ -96,10 +96,10 @@ export const INSTANCE_FEATURE_CATALOG: Record;
@@ -759,6 +770,12 @@ export const askUserQuestionsQuestionOptionSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
+ freeText: z
+ .boolean()
+ .optional()
+ .describe(
+ "When true, selecting this option reveals an inline text field; the typed value is returned as the question's otherText. Use this for a real \"I'll describe it\" choice instead of authoring a dead option that does nothing. At most one free-text option per question.",
+ ),
});
export const askUserQuestionsQuestionSchema = z.object({
@@ -789,6 +806,7 @@ export const askUserQuestionsPayloadSchema = z.object({
seenQuestionIds.add(question.id);
const seenOptionIds = new Set();
+ let freeTextOptionCount = 0;
for (const [optionIndex, option] of question.options.entries()) {
if (seenOptionIds.has(option.id)) {
ctx.addIssue({
@@ -798,6 +816,16 @@ export const askUserQuestionsPayloadSchema = z.object({
});
}
seenOptionIds.add(option.id);
+ if (option.freeText) {
+ freeTextOptionCount += 1;
+ if (freeTextOptionCount > 1) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "A question may declare at most one free-text option",
+ path: ["questions", questionIndex, "options", optionIndex, "freeText"],
+ });
+ }
+ }
}
}
});
@@ -815,8 +843,11 @@ export const askUserQuestionsResultSchema = z.object({
answers: z.array(askUserQuestionsAnswerSchema).max(20),
cancelled: z.literal(true).optional(),
cancellationReason: z.string().trim().max(4000).nullable().optional(),
- expirationReason: z.literal("superseded_by_comment").optional(),
+ expirationReason: z.enum(["superseded_by_comment", "superseded_by_newer_interaction"]).optional(),
commentId: z.string().uuid().nullable().optional(),
+ // Set alongside expirationReason "superseded_by_newer_interaction": the id of
+ // the newer sibling ask_user_questions that replaced this one (PAP-437).
+ supersededByInteractionId: z.string().uuid().nullable().optional(),
summaryMarkdown: z.string().max(20000).nullable().optional(),
});
diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts
index 5ab8b6dde07..880c53b30d4 100644
--- a/server/src/__tests__/built-in-agents.test.ts
+++ b/server/src/__tests__/built-in-agents.test.ts
@@ -569,7 +569,7 @@ describeEmbeddedPostgres("built-in agents", () => {
});
});
- it("auto-provisions a paused Reflection Coach bundle with skill sync and a disabled routine", async () => {
+ it("reconciles an enabled Reflection Coach bundle with skill sync and a disabled routine", async () => {
const companyId = await seedCompany({ requireApproval: false });
const root = await agentService(db).create(companyId, {
name: "CEO",
@@ -581,6 +581,17 @@ describeEmbeddedPostgres("built-in agents", () => {
permissions: {},
});
+ // The Reflection Coach is opt-in (not auto-created). Enabling it on demand
+ // materializes its managed bundle in a single pass.
+ const enabled = await builtInAgentService(db).ensure(companyId, "reflection-coach");
+ expect(enabled.agent?.adapterConfig).toMatchObject({
+ instructionsBundleMode: "managed",
+ instructionsEntryFile: "AGENTS.md",
+ });
+ expect(enabled.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
+
+ // Startup reconcile keeps the enabled bundle tracking stock and re-grants
+ // the root/company default permissions.
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result.autoEnsured).toBeGreaterThanOrEqual(1);
expect(result.defaultGrantsEnsured).toBeGreaterThanOrEqual(4);
@@ -606,11 +617,6 @@ describeEmbeddedPostgres("built-in agents", () => {
},
},
});
- expect(state.agent?.adapterConfig).toMatchObject({
- instructionsBundleMode: "managed",
- instructionsEntryFile: "AGENTS.md",
- });
- expect(state.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([
["instructions", "stock_current"],
["skill", "stock_current"],
@@ -693,7 +699,7 @@ describeEmbeddedPostgres("built-in agents", () => {
)).size).toBe(3);
});
- it("preserves new-agent approval gates during automatic Reflection Coach provisioning", async () => {
+ it("preserves new-agent approval gates during on-demand Reflection Coach provisioning", async () => {
const companyId = await seedCompany({ requireApproval: true });
const root = await agentService(db).create(companyId, {
name: "CEO",
@@ -710,12 +716,11 @@ describeEmbeddedPostgres("built-in agents", () => {
applyInSeparateFollowUpRun: true,
};
- const result = await reconcileBuiltInAgentsOnStartup(db);
-
- expect(result).toMatchObject({
- autoEnsured: 2,
- pendingApprovals: 2,
- });
+ // The Reflection Coach is opt-in, so it's enabled on demand. With board
+ // approval required, provisioning it must leave a pending agent + a
+ // hire_agent approval rather than an active agent.
+ const provisioned = await builtInAgentService(db).provision(companyId, "reflection-coach");
+ expect(provisioned.approval).not.toBeNull();
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(state).toMatchObject({
status: "pending_approval",
@@ -751,7 +756,7 @@ describeEmbeddedPostgres("built-in agents", () => {
});
const pendingReconcile = await reconcileBuiltInAgentsOnStartup(db);
- expect(pendingReconcile.pendingApprovals).toBe(2);
+ expect(pendingReconcile.pendingApprovals).toBe(1);
const stillPending = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(stillPending).toMatchObject({
status: "pending_approval",
@@ -787,7 +792,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
const approvalRows = await db.select().from(approvals).where(eq(approvals.companyId, companyId));
- expect(approvalRows).toHaveLength(2);
+ expect(approvalRows).toHaveLength(1);
});
it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => {
@@ -1062,7 +1067,21 @@ describeEmbeddedPostgres("built-in agents", () => {
const { olderId, newerId } = await seedLegacyDuplicateBriefs(affectedCompanyId);
// A second company created after the affected one — previously skipped
// entirely because the duplicate error escaped the reconciliation loop.
+ // Give it a drifted built-in row so we can prove reconcile still reached it.
const healthyCompanyId = await seedCompany({ requireApproval: false });
+ const healthyBriefsId = randomUUID();
+ await db.insert(agents).values({
+ id: healthyBriefsId,
+ companyId: healthyCompanyId,
+ name: "Stale Briefs",
+ role: "engineer",
+ status: "idle",
+ adapterType: "codex_local",
+ adapterConfig: { model: "gpt-5.4" },
+ runtimeConfig: {},
+ permissions: {},
+ metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }),
+ });
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result.companyFailures).toBe(0);
@@ -1077,9 +1096,10 @@ describeEmbeddedPostgres("built-in agents", () => {
),
).toHaveLength(1);
- // The company after the affected one still had its bundled agents provisioned.
- const healthyCoach = await builtInAgentService(db).get(healthyCompanyId, "reflection-coach");
- expect(healthyCoach.agentId).toBeTruthy();
+ // The company after the affected one was still reconciled (its drifted
+ // built-in row was repaired to stock) rather than skipped.
+ const [healthyBriefs] = await db.select().from(agents).where(eq(agents.id, healthyBriefsId));
+ expect(healthyBriefs?.name).toBe("Briefs Agent");
});
it("automatically materializes the Reflection Coach bundle without enabling background work", async () => {
diff --git a/server/src/__tests__/companies-service.test.ts b/server/src/__tests__/companies-service.test.ts
index c8b48ddb74b..e3aa0c7ae4d 100644
--- a/server/src/__tests__/companies-service.test.ts
+++ b/server/src/__tests__/companies-service.test.ts
@@ -24,7 +24,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { companyService } from "../services/companies.js";
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
-import { reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
+import { builtInAgentService, reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -81,19 +81,28 @@ describeEmbeddedPostgres("companyService", () => {
expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]);
});
- it("auto-provisions one paused Reflection Coach bundle for a freshly created company", async () => {
+ it("does not auto-provision bundled built-in agents for a freshly created company", async () => {
const created = await companyService(db).create({
name: "Fresh Company",
});
+ // A new company starts clean: the Reflection Coach and Summarizer are
+ // opt-in, not seeded by default for a new user.
const agentRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
- const reflectionRows = agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach");
- expect(reflectionRows).toHaveLength(1);
- expect(reflectionRows[0]).toMatchObject({
+ expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0);
+
+ // Startup reconcile leaves a fresh company untouched — nothing is created.
+ await reconcileBuiltInAgentsOnStartup(db);
+ const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
+ expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0);
+
+ // The Reflection Coach remains available to enable on demand, and enabling
+ // it materializes its bundled skill + paused routine.
+ const enabled = await builtInAgentService(db).ensure(created.id, "reflection-coach");
+ expect(enabled.agent).toMatchObject({
name: "Reflection Coach",
status: "paused",
budgetMonthlyCents: 0,
- spentMonthlyCents: 0,
});
const [skill] = await db
@@ -110,10 +119,10 @@ describeEmbeddedPostgres("companyService", () => {
const [routine] = await db
.select()
.from(routines)
- .where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, reflectionRows[0]!.id)));
+ .where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, enabled.agentId!)));
expect(routine).toMatchObject({
status: "paused",
- assigneeAgentId: reflectionRows[0]!.id,
+ assigneeAgentId: enabled.agentId,
originKind: "built_in_agent_bundle",
originId: "reflection-coach:recent-agent-reflection",
});
@@ -122,10 +131,6 @@ describeEmbeddedPostgres("companyService", () => {
kind: "schedule",
enabled: false,
});
-
- await reconcileBuiltInAgentsOnStartup(db);
- const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
- expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
});
it("archives companies by pausing runnable agents and cancelling active runs", async () => {
diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts
index a31adbf4cb9..342eacb0a0e 100644
--- a/server/src/__tests__/instance-settings-service.test.ts
+++ b/server/src/__tests__/instance-settings-service.test.ts
@@ -29,7 +29,7 @@ describe("instance settings service", () => {
enableStreamlinedLeftNavigation: true,
enableApps: false,
enableConferenceRoomChat: false,
- enableTaskChatRedesign: false,
+ enableClassicTaskInterface: false,
enableExternalObjects: false,
enableSmokeLab: false,
enablePipelines: false,
@@ -72,6 +72,20 @@ describe("instance settings service", () => {
).toBe(false);
});
+ it("defaults enableClassicTaskInterface to false for empty and legacy stored settings", () => {
+ expect(normalizeExperimentalSettings(undefined).enableClassicTaskInterface).toBe(false);
+ expect(normalizeExperimentalSettings({}).enableClassicTaskInterface).toBe(false);
+ // The retired enableTaskChatRedesign key must not bleed into the new flag:
+ // an install that had the chat redesign ON opted into chat-style, which is
+ // now the default — not into the classic view.
+ expect(
+ normalizeExperimentalSettings({ enableTaskChatRedesign: true }).enableClassicTaskInterface,
+ ).toBe(false);
+ expect(
+ normalizeExperimentalSettings({ enableClassicTaskInterface: true }).enableClassicTaskInterface,
+ ).toBe(true);
+ });
+
it("defaults enableSimplifiedEnglishInteractions to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableSimplifiedEnglishInteractions).toBe(false);
expect(normalizeExperimentalSettings({}).enableSimplifiedEnglishInteractions).toBe(false);
diff --git a/server/src/__tests__/issue-onboarding-first-task-routes.test.ts b/server/src/__tests__/issue-onboarding-first-task-routes.test.ts
new file mode 100644
index 00000000000..01b3078ceba
--- /dev/null
+++ b/server/src/__tests__/issue-onboarding-first-task-routes.test.ts
@@ -0,0 +1,168 @@
+import { randomUUID } from "node:crypto";
+import express from "express";
+import request from "supertest";
+import { and, eq } from "drizzle-orm";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import {
+ activityLog,
+ agents,
+ agentWakeupRequests,
+ companies,
+ createDb,
+ issueComments,
+ issues,
+} from "@paperclipai/db";
+import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
+import {
+ getEmbeddedPostgresTestSupport,
+ startEmbeddedPostgresTestDatabase,
+} from "./helpers/embedded-postgres.js";
+import { actorMiddleware } from "../middleware/auth.js";
+import { errorHandler } from "../middleware/index.js";
+import { issueRoutes } from "../routes/issues.js";
+
+const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
+const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
+
+if (!embeddedPostgresSupport.supported) {
+ console.warn(
+ `Skipping embedded Postgres onboarding first-task route tests on this host: ${
+ embeddedPostgresSupport.reason ?? "unsupported environment"
+ }`,
+ );
+}
+
+describeEmbeddedPostgres("issue create onboarding first-task routes", () => {
+ let db!: ReturnType;
+ let tempDb: Awaited> | null = null;
+
+ beforeAll(async () => {
+ tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-onboarding-first-task-routes-");
+ db = createDb(tempDb.connectionString);
+ }, 20_000);
+
+ afterEach(async () => {
+ await db.delete(activityLog);
+ await db.delete(agentWakeupRequests);
+ await db.delete(issueComments);
+ await db.delete(issues);
+ await db.delete(agents);
+ await db.delete(companies);
+ });
+
+ afterAll(async () => {
+ await tempDb?.cleanup();
+ });
+
+ function createApp() {
+ const app = express();
+ app.use(express.json());
+ app.use(actorMiddleware(db, { deploymentMode: "local_trusted" }));
+ app.use("/api", issueRoutes(db, {} as any));
+ app.use(errorHandler);
+ return app;
+ }
+
+ async function seedCompany() {
+ const companyId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `D${companyId.replace(/-/g, "").slice(0, 5).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+ return companyId;
+ }
+
+ async function seedAgent(companyId: string) {
+ const agentId = randomUUID();
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "CEO",
+ role: "engineer",
+ status: "running",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ });
+ return agentId;
+ }
+
+ function listOnboardingIssues(companyId: string) {
+ return db
+ .select()
+ .from(issues)
+ .where(and(
+ eq(issues.companyId, companyId),
+ eq(issues.originKind, ONBOARDING_FIRST_TASK_ORIGIN_KIND),
+ ));
+ }
+
+ it("stamps the onboarding origin and seeds the agent-attributed greeting on the first task", async () => {
+ const companyId = await seedCompany();
+ const agentId = await seedAgent(companyId);
+ const app = createApp();
+
+ const created = await request(app)
+ .post(`/api/companies/${companyId}/issues`)
+ .send({ title: "Get started", onboardingFirstTask: true, assigneeAgentId: agentId })
+ .expect(201);
+
+ expect(created.body.originKind).toBe(ONBOARDING_FIRST_TASK_ORIGIN_KIND);
+ const comments = await db
+ .select()
+ .from(issueComments)
+ .where(eq(issueComments.issueId, created.body.id));
+ expect(comments).toHaveLength(1);
+ expect(comments[0]).toMatchObject({ authorType: "agent", authorAgentId: agentId });
+ });
+
+ it("fails closed to an ordinary issue when the onboarding origin is already claimed", async () => {
+ const companyId = await seedCompany();
+ const agentId = await seedAgent(companyId);
+ const app = createApp();
+ // Simulate the losing side of the count-vs-create race: the winning issue
+ // already claimed the onboarding origin but is hidden, so the zero-count
+ // fast path still passes and only issues_onboarding_first_task_uq rejects
+ // the privileged insert.
+ await db.insert(issues).values({
+ companyId,
+ title: "Race winner",
+ status: "todo",
+ priority: "medium",
+ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND,
+ hiddenAt: new Date(),
+ });
+
+ const created = await request(app)
+ .post(`/api/companies/${companyId}/issues`)
+ .send({ title: "Race loser", onboardingFirstTask: true, assigneeAgentId: agentId })
+ .expect(201);
+
+ expect(created.body.originKind).toBe("manual");
+ const comments = await db
+ .select()
+ .from(issueComments)
+ .where(eq(issueComments.issueId, created.body.id));
+ expect(comments).toHaveLength(0);
+ expect(await listOnboardingIssues(companyId)).toHaveLength(1);
+ });
+
+ it("allows at most one onboarding first task across concurrent creates", async () => {
+ const companyId = await seedCompany();
+ const app = createApp();
+
+ const responses = await Promise.all(
+ ["Kick off A", "Kick off B", "Kick off C"].map((title) =>
+ request(app)
+ .post(`/api/companies/${companyId}/issues`)
+ .send({ title, onboardingFirstTask: true }),
+ ),
+ );
+
+ for (const response of responses) expect(response.status).toBe(201);
+ expect(await listOnboardingIssues(companyId)).toHaveLength(1);
+ });
+});
diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts
index 1403e642cb0..3358935d1de 100644
--- a/server/src/__tests__/issue-thread-interactions-service.test.ts
+++ b/server/src/__tests__/issue-thread-interactions-service.test.ts
@@ -24,6 +24,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
+import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import { instanceSettingsService } from "../services/instance-settings.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
@@ -1262,6 +1263,190 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
});
+ it("supersedes an agent's own older pending ask_user_questions without crossing agent, kind, or issue", async () => {
+ const { companyId, goalId, issueId } = await seedConfirmationIssue("Question supersedes older sibling");
+ const otherIssueId = randomUUID();
+ await db.insert(issues).values({
+ id: otherIssueId,
+ companyId,
+ goalId,
+ title: "Other issue",
+ status: "in_progress",
+ priority: "medium",
+ });
+
+ const probingAgentId = randomUUID();
+ const otherAgentId = randomUUID();
+ await db.insert(agents).values([
+ {
+ id: probingAgentId,
+ companyId,
+ name: "Probing agent",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ },
+ {
+ id: otherAgentId,
+ companyId,
+ name: "Other agent",
+ role: "engineer",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ },
+ ]);
+
+ const question = (prompt: string) => ({
+ kind: "ask_user_questions" as const,
+ payload: {
+ version: 1 as const,
+ questions: [{
+ id: "q",
+ prompt,
+ selectionMode: "single" as const,
+ options: [{ id: "opt", label: "Option" }],
+ }],
+ },
+ });
+
+ const older = await interactionsSvc.create(
+ { id: issueId, companyId }, question("Older question"), { agentId: probingAgentId },
+ );
+ const otherKind = await interactionsSvc.create({ id: issueId, companyId }, {
+ kind: "request_confirmation",
+ payload: { version: 1, prompt: "Approve the draft?" },
+ }, { agentId: probingAgentId });
+ const otherAgentQuestion = await interactionsSvc.create(
+ { id: issueId, companyId }, question("Other agent question"), { agentId: otherAgentId },
+ );
+ const otherIssueQuestion = await interactionsSvc.create(
+ { id: otherIssueId, companyId }, question("Other issue question"), { agentId: probingAgentId },
+ );
+ const replacement = await interactionsSvc.create(
+ { id: issueId, companyId }, question("Newer question"), { agentId: probingAgentId },
+ );
+
+ const interactions = await interactionsSvc.listForIssue(issueId);
+ expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({
+ status: "expired",
+ resolvedByAgentId: probingAgentId,
+ result: {
+ answers: [],
+ expirationReason: "superseded_by_newer_interaction",
+ supersededByInteractionId: replacement.id,
+ },
+ });
+ expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending");
+ // A different agent's pending question is untouched.
+ expect(interactions.find((interaction) => interaction.id === otherAgentQuestion.id)?.status).toBe("pending");
+ // A different kind from the same agent is untouched.
+ expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
+
+ // The same agent's question on a different issue is untouched.
+ const otherIssueInteractions = await interactionsSvc.listForIssue(otherIssueId);
+ expect(otherIssueInteractions.find((interaction) => interaction.id === otherIssueQuestion.id)?.status)
+ .toBe("pending");
+ });
+
+ it("leaves exactly one pending ask_user_questions on the onboarding first task after probe cards and the real question arrive", async () => {
+ const companyId = randomUUID();
+ const goalId = randomUUID();
+ const issueId = randomUUID();
+ const agentId = randomUUID();
+ await db.insert(companies).values({
+ id: companyId,
+ name: "Paperclip",
+ issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
+ requireBoardApprovalForNewAgents: false,
+ });
+ await db.insert(agents).values({
+ id: agentId,
+ companyId,
+ name: "Chief of staff",
+ role: "chief_of_staff",
+ status: "active",
+ adapterType: "codex_local",
+ adapterConfig: {},
+ runtimeConfig: {},
+ permissions: {},
+ });
+ await db.insert(goals).values({
+ id: goalId,
+ companyId,
+ title: "Your first task",
+ level: "task",
+ status: "active",
+ });
+ await db.insert(issues).values({
+ id: issueId,
+ companyId,
+ goalId,
+ title: "Your first task",
+ status: "in_progress",
+ priority: "medium",
+ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND,
+ assigneeAgentId: agentId,
+ });
+
+ // Reproduces PAP-436: the assigned agent posts two throwaway schema probes
+ // (title/prompt/option "t"/"p"/"L") before the genuine question.
+ const probe = (prompt: string) => ({
+ kind: "ask_user_questions" as const,
+ payload: {
+ version: 1 as const,
+ questions: [{
+ id: "q",
+ prompt,
+ selectionMode: "single" as const,
+ options: [{ id: "L", label: "L" }],
+ }],
+ },
+ });
+ await interactionsSvc.create({ id: issueId, companyId }, probe("t"), { agentId });
+ await interactionsSvc.create({ id: issueId, companyId }, probe("p"), { agentId });
+ await interactionsSvc.create({ id: issueId, companyId }, {
+ kind: "ask_user_questions",
+ payload: {
+ version: 1,
+ questions: [{
+ id: "focus",
+ prompt: "What would you like your team to focus on first?",
+ selectionMode: "single",
+ options: [
+ { id: "mvp", label: "Ship the MVP" },
+ { id: "bugs", label: "Fix bugs" },
+ ],
+ }],
+ },
+ }, { agentId });
+
+ const interactions = await interactionsSvc.listForIssue(issueId);
+ const pendingQuestions = interactions.filter(
+ (interaction) => interaction.kind === "ask_user_questions" && interaction.status === "pending",
+ );
+ expect(pendingQuestions).toHaveLength(1);
+ expect(pendingQuestions[0]?.kind).toBe("ask_user_questions");
+ const [remaining] = pendingQuestions;
+ if (remaining?.kind === "ask_user_questions") {
+ expect(remaining.payload.questions[0]?.prompt).toContain("focus on first");
+ }
+
+ // Both probe cards auto-expired with the sibling-supersede reason.
+ const expiredQuestions = interactions.filter(
+ (interaction) => interaction.kind === "ask_user_questions" && interaction.status === "expired",
+ );
+ expect(expiredQuestions).toHaveLength(2);
+ for (const card of expiredQuestions) {
+ expect(card.result).toMatchObject({ expirationReason: "superseded_by_newer_interaction" });
+ }
+ });
+
it("sweeps historical confirmation pile-ups idempotently per issue, kind, and agent", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Historical confirmation sweep");
const firstAgentId = randomUUID();
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index 02084fe7cfb..ac605bd6a56 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -56,6 +56,7 @@ import {
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
ISSUE_WATCHDOG_DISCOVERY_KINDS,
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
+ ONBOARDING_FIRST_TASK_ORIGIN_KIND,
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
@@ -166,6 +167,10 @@ import {
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
+import {
+ buildOnboardingGreeting,
+ ONBOARDING_GREETING_AUTHORIZATION_REASON,
+} from "../services/onboarding-greeting.js";
import {
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
buildIssueBlockersResolvedWakeIdempotencyKey,
@@ -592,6 +597,27 @@ function authenticatedActorResponsibleUserId(req: Request) {
return req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : undefined;
}
+// Matches the partial unique index that guarantees at most one onboarding
+// first-task issue per company (packages/db/src/schema/issues.ts).
+function isOnboardingFirstTaskConflict(error: unknown): boolean {
+ for (
+ let current = error, depth = 0;
+ current && typeof current === "object" && depth < 5;
+ current = (current as { cause?: unknown }).cause, depth += 1
+ ) {
+ const candidate = current as { code?: string; constraint?: string; message?: string };
+ if (
+ candidate.code === "23505" &&
+ (candidate.constraint === "issues_onboarding_first_task_uq" ||
+ (typeof candidate.message === "string" &&
+ candidate.message.includes("issues_onboarding_first_task_uq")))
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
function issueWriteAuthorizationReason(
req: Request,
decision: true | { reason?: string | null },
@@ -7625,7 +7651,34 @@ export function issueRoutes(
surface: "issues.create",
});
if (!sanitizedBody) return;
- const { watchdogDiscovery: rawWatchdogDiscovery, ...rawCreateBody } = sanitizedBody;
+ const {
+ watchdogDiscovery: rawWatchdogDiscovery,
+ onboardingFirstTask: rawOnboardingFirstTask,
+ ...rawCreateBody
+ } = sanitizedBody;
+ // The onboarding first-task marker grants privileged, server-owned behavior:
+ // it stamps the onboarding origin (which suppresses the seeded description in
+ // the UI) and seeds a comment authored *as the assigned agent*. Honor it only
+ // when the request is genuinely the onboarding wizard creating a company's
+ // very first task, verified server-side so a client marker alone cannot
+ // trigger it:
+ // 1. the caller is a human board/user session (the wizard never runs as an
+ // agent), and
+ // 2. the company has no existing issues yet — i.e. this really is the first
+ // task. An established company creating an ordinary issue can never reach
+ // the greeting/description-suppression path, so no board caller can
+ // fabricate a statement attributed to an assigned agent on a normal task.
+ // Fails closed: if it is not verifiably the first task, the flag is ignored
+ // and an ordinary issue is created. The zero-count read below is only a
+ // fast-path gate — overlapping requests could both observe zero — so the
+ // partial unique index issues_onboarding_first_task_uq is what atomically
+ // enforces at most one onboarding first task per company; the create call
+ // handles losing that race by degrading to an ordinary issue.
+ const onboardingFirstTaskRequested =
+ rawOnboardingFirstTask === true && req.actor.type === "board";
+ let isOnboardingFirstTask = onboardingFirstTaskRequested
+ ? (await svc.count(companyId)) === 0
+ : false;
const watchdogDiscovery = normalizeWatchdogDiscovery(rawWatchdogDiscovery);
const watchdogProductBugFollowUp = await resolveTaskWatchdogProductBugFollowUp(
req,
@@ -7680,6 +7733,9 @@ export function issueRoutes(
...(runWorkspaceInheritanceSourceIssueId
? { inheritExecutionWorkspaceFromIssueId: runWorkspaceInheritanceSourceIssueId }
: {}),
+ ...(isOnboardingFirstTask && !watchdogProductBugFollowUp
+ ? { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }
+ : {}),
...(watchdogProductBugFollowUp
? {
description: appendWatchdogDiscoveryContext({
@@ -7734,7 +7790,7 @@ export function issueRoutes(
executionPolicy,
}, actor);
let deduplicationReason: "idempotency_key" | "recent_open_title" | null = null;
- const issue = await svc.create(companyId, {
+ const createInput = {
...createBody,
...(taskBridgeOriginForActor(req) ?? {}),
id: issueId,
@@ -7747,10 +7803,23 @@ export function issueRoutes(
actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
trustExplicitResponsibleUserId: actor.actorType === "user",
watchdogActorRunId: actor.runId,
- onDeduplicated: (reason) => {
+ onDeduplicated: (reason: "idempotency_key" | "recent_open_title") => {
deduplicationReason = reason;
},
- });
+ };
+ let issue: Awaited>;
+ try {
+ issue = await svc.create(companyId, createInput);
+ } catch (error) {
+ // Concurrent onboarding creates can both pass the zero-count fast path;
+ // the issues_onboarding_first_task_uq index rejects the loser here. Fail
+ // closed: drop the privileged origin (and with it the agent-attributed
+ // greeting) and create an ordinary issue instead.
+ if (!(isOnboardingFirstTask && isOnboardingFirstTaskConflict(error))) throw error;
+ isOnboardingFirstTask = false;
+ const { originKind: _onboardingOriginKind, ...ordinaryCreateInput } = createInput;
+ issue = await svc.create(companyId, ordinaryCreateInput);
+ }
if (deduplicationReason) {
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
res.status(200).json({
@@ -7849,6 +7918,39 @@ export function issueRoutes(
});
}
+ // Seed the onboarding first-task greeting as an agent-authored comment so the
+ // user lands on a waiting greeting (instead of a right-aligned "user" bubble
+ // showing the seeded description). Deterministic template — no LLM call — and
+ // best-effort: a greeting failure must not fail issue creation.
+ if (isOnboardingFirstTask && issue.assigneeAgentId) {
+ try {
+ const [company, goal, assigneeAgent] = await Promise.all([
+ companiesSvc.getById(companyId),
+ createBody.goalId ? goalsSvc.getById(createBody.goalId) : Promise.resolve(null),
+ agentsSvc.getById(issue.assigneeAgentId),
+ ]);
+ const greetingBody = buildOnboardingGreeting({
+ agentName: assigneeAgent?.name ?? null,
+ teamName: company?.name ?? null,
+ goals: goal?.description ?? goal?.title ?? null,
+ });
+ await svc.addComment(
+ issue.id,
+ greetingBody,
+ { agentId: issue.assigneeAgentId },
+ {
+ authorType: "agent",
+ authorizationReason: ONBOARDING_GREETING_AUTHORIZATION_REASON,
+ },
+ );
+ } catch (err) {
+ logger.warn(
+ { err, issueId: issue.id, companyId },
+ "failed to seed onboarding first-task greeting",
+ );
+ }
+ }
+
void queueIssueAssignmentWakeup({
heartbeat,
issue,
diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts
index 40c46efe72c..718263668eb 100644
--- a/server/src/services/built-in-agents.ts
+++ b/server/src/services/built-in-agents.ts
@@ -471,6 +471,12 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition]));
+// Bundled built-in agents that should be provisioned automatically when a
+// company is created (and re-ensured on startup reconcile). Empty by default so
+// a new user starts clean — the Reflection Coach and Summarizer are opt-in, not
+// seeded. Add a definition key here to restore automatic provisioning.
+const AUTO_PROVISION_ON_COMPANY_CREATE_KEYS = new Set([]);
+
const ROOT_AGENT_DEFAULT_CHANGE_GRANTS: PermissionKey[] = ["agents:configure", "skills:create"];
const BUILT_IN_AGENT_DEFAULT_GRANTS: Record = {
"reflection-coach": ["agents:suggest-changes", "skills:suggest-changes"],
@@ -1917,7 +1923,17 @@ export function builtInAgentService(db: Db) {
const company = await ensureCompany(companyId);
let autoEnsured = 0;
let pendingApprovals = 0;
+ // A fresh company starts with only its own lead agent — the Reflection
+ // Coach and Summarizer are no longer auto-created for new users. They stay
+ // available to enable on demand (via ensure / provision / the built-in
+ // bundle panel). We still reconcile any bundled agent that already exists
+ // (e.g. one an operator enabled) so its instructions/skill/routine keep
+ // tracking stock. Add a key to AUTO_PROVISION_ON_COMPANY_CREATE_KEYS to
+ // restore automatic creation for that definition.
for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) {
+ const existing = await findSingleAgent(companyId, definition);
+ const shouldProvision = existing !== null || AUTO_PROVISION_ON_COMPANY_CREATE_KEYS.has(definition.key);
+ if (!shouldProvision) continue;
if (company.requireBoardApprovalForNewAgents) {
const result = await provision(companyId, definition.key);
if (result.approval) pendingApprovals += 1;
diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts
index 9be8b2e1a01..8f3f392bf55 100644
--- a/server/src/services/instance-settings.ts
+++ b/server/src/services/instance-settings.ts
@@ -214,7 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
- enableTaskChatRedesign: parsed.data.enableTaskChatRedesign ?? false,
+ enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
@@ -250,7 +250,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
- enableTaskChatRedesign: false,
+ enableClassicTaskInterface: false,
enableTaskWatchdogs: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts
index 351da2a3464..57171354a9a 100644
--- a/server/src/services/issue-thread-interactions.ts
+++ b/server/src/services/issue-thread-interactions.ts
@@ -565,6 +565,21 @@ function buildSupersededByNewerRequestResult(replacementInteractionId: string) {
} as const;
}
+// An agent that posts a fresh ask_user_questions while its own earlier ones on
+// the same issue are still pending has replaced them — the newer card carries
+// the real ask, so the stale siblings auto-expire (PAP-437). Mirrors the
+// `superseded_by_comment` shape (ask_user_questions results key expiry off
+// `expirationReason`, not `outcome`) so the UI can hide them cleanly.
+function buildSupersededByNewerInteractionResult(replacementInteractionId: string) {
+ return {
+ version: 1,
+ answers: [],
+ expirationReason: "superseded_by_newer_interaction",
+ supersededByInteractionId: replacementInteractionId,
+ summaryMarkdown: null,
+ } as const;
+}
+
function buildAdministrativeOutcomeResult(
row: IssueThreadInteractionRow,
outcome: "withdrawn" | "issue_closed" | "addressee_deleted",
@@ -1888,16 +1903,27 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
})
.returning();
- if (data.kind !== "request_confirmation" || !actor.agentId) {
+ // An agent replacing its own still-pending card supersedes the older
+ // one so the thread never accumulates stale sibling cards. This covers
+ // request_confirmation drafts and ask_user_questions (PAP-437: probe
+ // question cards that agents never withdrew). Each kind keeps its own
+ // result shape. Scoped strictly to the same agent + issue + kind, so
+ // other agents' or other kinds' pending cards are untouched.
+ const canSupersedeSiblingCards =
+ data.kind === "request_confirmation" || data.kind === "ask_user_questions";
+ if (!actor.agentId || !canSupersedeSiblingCards) {
return { row, supersededRows: [] };
}
const now = new Date();
+ const supersededResult = data.kind === "ask_user_questions"
+ ? buildSupersededByNewerInteractionResult(row.id)
+ : buildSupersededByNewerRequestResult(row.id);
const supersededRows = await tx
.update(issueThreadInteractions)
.set({
status: "expired",
- result: buildSupersededByNewerRequestResult(row.id),
+ result: supersededResult,
resolvedByAgentId: actor.agentId,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,
diff --git a/server/src/services/onboarding-greeting.test.ts b/server/src/services/onboarding-greeting.test.ts
new file mode 100644
index 00000000000..4bb5f2550ee
--- /dev/null
+++ b/server/src/services/onboarding-greeting.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+import { buildOnboardingGreeting } from "./onboarding-greeting.js";
+
+describe("buildOnboardingGreeting", () => {
+ it("introduces the agent by name as the user's first teammate and reflects the goals", () => {
+ const greeting = buildOnboardingGreeting({
+ agentName: "Nova",
+ teamName: "Acme",
+ goals: "Launch a marketplace for local makers.",
+ });
+
+ expect(greeting).toContain(
+ "Welcome! I'm Nova, your first agent teammate on Paperclip.",
+ );
+ expect(greeting).toContain("Here's what I understand you're aiming for:");
+ expect(greeting).toContain("> Launch a marketplace for local makers.");
+ expect(greeting).toContain("propose a team of agents");
+ expect(greeting).toContain("few focused questions");
+ });
+
+ it("falls back to a generic teammate intro when no agent name is set", () => {
+ const greeting = buildOnboardingGreeting({ agentName: null, goals: null });
+
+ expect(greeting).toContain(
+ "Welcome! I'm your first agent teammate on Paperclip.",
+ );
+ });
+
+ it("collapses whitespace in the reflected goals", () => {
+ const greeting = buildOnboardingGreeting({
+ goals: " Build\n\n a SaaS product. ",
+ });
+
+ expect(greeting).toContain("> Build a SaaS product.");
+ });
+
+ it("omits the reflect-back block when no goals are provided", () => {
+ const greeting = buildOnboardingGreeting({ agentName: "Nova", goals: null });
+
+ expect(greeting).not.toContain("aiming for");
+ expect(greeting).toContain("propose a team of agents");
+ });
+});
diff --git a/server/src/services/onboarding-greeting.ts b/server/src/services/onboarding-greeting.ts
new file mode 100644
index 00000000000..4c4ae5dc5e1
--- /dev/null
+++ b/server/src/services/onboarding-greeting.ts
@@ -0,0 +1,39 @@
+// Deterministic, template-driven greeting seeded as an agent-authored comment on
+// the onboarding first task. No LLM call: it reflects back the onboarding context
+// (team name + goals) so the user lands on a waiting greeting instead of a
+// right-aligned "user" bubble showing the agent's own seeded instructions.
+
+export const ONBOARDING_GREETING_AUTHORIZATION_REASON = "onboarding first-task greeting";
+
+export function buildOnboardingGreeting(input: {
+ agentName?: string | null;
+ teamName?: string | null;
+ goals?: string | null;
+}): string {
+ const agentName = input.agentName?.trim();
+ const goals = input.goals?.replace(/\s+/g, " ").trim();
+
+ // Introduce the agent by the name the user chose in onboarding when we have
+ // it, so the first message reads as coming from *their* first teammate rather
+ // than a generic agent. Fall back to the generic phrasing otherwise.
+ const identity = agentName
+ ? `Welcome! I'm ${agentName}, your first agent teammate on Paperclip.`
+ : "Welcome! I'm your first agent teammate on Paperclip.";
+
+ const lines: string[] = [];
+ lines.push(identity);
+
+ if (goals) {
+ lines.push("");
+ lines.push("Here's what I understand you're aiming for:");
+ lines.push("");
+ lines.push(`> ${goals}`);
+ }
+
+ lines.push("");
+ lines.push(
+ "I want to gather more context so I can come up with a plan and propose a team of agents to help execute it. I'm putting together a few focused questions so we can settle on a concrete goal to tackle first. Please give me one moment...",
+ );
+
+ return lines.join("\n");
+}
diff --git a/tests/e2e/conference-room-typing-intro.spec.ts b/tests/e2e/conference-room-typing-intro.spec.ts
index e1a7096ed6b..6d528bc99f4 100644
--- a/tests/e2e/conference-room-typing-intro.spec.ts
+++ b/tests/e2e/conference-room-typing-intro.spec.ts
@@ -1,105 +1,157 @@
-import { test, expect } from "@playwright/test";
+import { test, expect, type Page } from "@playwright/test";
+import {
+ expectLandsOnFirstTaskWithoutDashboardBounce,
+ instrumentNavLog,
+} from "./helpers/onboarding-landing";
/**
* E2E: post-wizard onboarding launch.
*
* Completing the onboarding wizard now creates the first assigned task and
- * lands the user on the company dashboard. The chat intro still has unit
- * coverage in BoardChat tests; the wizard handoff no longer routes there.
+ * drops the user straight onto that task's detail page (not the dashboard),
+ * so they land in the conversation the agent will start in. The chat intro
+ * still has unit coverage in BoardChat tests.
+ *
+ * PAP-404: onboarding used to intermittently bounce to the company dashboard.
+ * The bounce only reproduces when the instance already has ≥1 company (the
+ * board's test ports), so the second test seeds a company first to exercise
+ * exactly that failing condition.
*/
-const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`;
-const MISSION = "Verify the dashboard launch survives the wizard handoff.";
-const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan";
+const MISSION = "Verify the first-task launch survives the wizard handoff.";
+const FIRST_TASK_TITLE = "Paperclip onboarding";
-test.describe("Dashboard launch after onboarding wizard", () => {
- test("creates the first task and opens the dashboard", async ({
+/**
+ * Intercept the two side-effecting calls the wizard makes so no real CLI check
+ * runs and no real agent process spawns (the hire still happens server-side
+ * with an inert http adapter).
+ */
+async function installLaunchIntercepts(page: Page, baseURL?: string) {
+ await page.route("**/test-environment", (route) =>
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({ status: "pass", checks: [] }),
+ }),
+ );
+
+ await page.route("**/agent-hires", async (route) => {
+ const req = route.request();
+ const body = JSON.parse(req.postData() || "{}");
+ const auth = req.headers().authorization;
+ const real = await fetch(new URL(req.url(), baseURL).toString(), {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(auth ? { Authorization: auth } : {}),
+ },
+ body: JSON.stringify({
+ name: body.name,
+ role: body.role,
+ adapterType: "http",
+ adapterConfig: { url: "http://127.0.0.1:1/dead" },
+ runtimeConfig: { heartbeat: { enabled: false } },
+ }),
+ });
+ await route.fulfill({
+ status: real.status,
+ contentType: "application/json",
+ body: await real.text(),
+ });
+ });
+}
+
+/** Drive the wizard from the /onboarding route through to "Get started". */
+async function runOnboardingWizard(page: Page, companyName: string) {
+ await page.goto("/onboarding");
+
+ // Launcher card path (existing companies) — enter the wizard if the
+ // route shows a launcher instead of opening the wizard directly.
+ const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
+ if (await startBtn.count()) await startBtn.first().click();
+
+ // Step 0: front door (skipped when the wizard opens on the create path).
+ const frontDoor = page.getByText("Build a new company");
+ if (await frontDoor.count()) await frontDoor.first().click();
+
+ // Step 1: company name.
+ await page.getByPlaceholder("Acme Corp").fill(companyName);
+ await page.getByRole("button", { name: /^Next/ }).click();
+
+ // Step 2: mission (direct path default).
+ await page.getByPlaceholder("What is your team trying to achieve?").fill(MISSION);
+ await page.getByRole("button", { name: /Confirm mission/ }).click();
+
+ // Step 3: lead name (prefilled) → Next.
+ await page.waitForSelector('input[placeholder="Chief of staff"]', {
+ timeout: 15_000,
+ });
+ await page.getByRole("button", { name: /^Next/ }).click();
+
+ // Step 4: adapter (claude_local default); heartbeat is intercepted.
+ await page.getByRole("button", { name: /^Connect$/ }).click();
+
+ // Step 5: review → Get started creates the first task and opens its
+ // detail page.
+ const getStarted = page.getByRole("button", { name: /Get started/ });
+ await getStarted.waitFor({ timeout: 20_000 });
+ await getStarted.click();
+}
+
+async function assertFirstTaskExists(page: Page, companyName: string) {
+ const companiesRes = await page.request.get("/api/companies");
+ expect(companiesRes.ok()).toBe(true);
+ const companies = await companiesRes.json();
+ const company = companies.find(
+ (candidate: { name: string }) => candidate.name === companyName,
+ );
+ expect(company).toBeTruthy();
+
+ const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
+ expect(issuesRes.ok()).toBe(true);
+ const issues = await issuesRes.json();
+ const firstTask = issues.find(
+ (candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE,
+ );
+ expect(firstTask).toBeTruthy();
+ await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({
+ timeout: 15_000,
+ });
+}
+
+test.describe("First-task launch after onboarding wizard", () => {
+ test("creates the first task and opens its detail page", async ({ page, baseURL }) => {
+ await instrumentNavLog(page);
+ await installLaunchIntercepts(page, baseURL);
+
+ const companyName = `E2E-TypingIntro-${Date.now()}`;
+ await runOnboardingWizard(page, companyName);
+
+ await expectLandsOnFirstTaskWithoutDashboardBounce(page);
+ await assertFirstTaskExists(page, companyName);
+ });
+
+ // PAP-404 regression: the dashboard bounce only fires when the instance
+ // already has a company for the route-sync effect to reset selection to.
+ // Seed one first, then onboard a brand-new company and assert we still land
+ // on the first task without a dashboard bounce.
+ test("lands on the first task even when a company already exists", async ({
page,
baseURL,
}) => {
- // Intercept env-test → instant pass (avoid running a real CLI check).
- await page.route("**/test-environment", (route) =>
- route.fulfill({
- contentType: "application/json",
- body: JSON.stringify({ status: "pass", checks: [] }),
- }),
- );
+ await instrumentNavLog(page);
+ await installLaunchIntercepts(page, baseURL);
- // Intercept hire → perform a REAL hire server-side with an inert http
- // adapter so no real agent process spawns.
- await page.route("**/agent-hires", async (route) => {
- const req = route.request();
- const body = JSON.parse(req.postData() || "{}");
- const auth = req.headers().authorization;
- const real = await fetch(new URL(req.url(), baseURL).toString(), {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...(auth ? { Authorization: auth } : {}),
- },
- body: JSON.stringify({
- name: body.name,
- role: body.role,
- adapterType: "http",
- adapterConfig: { url: "http://127.0.0.1:1/dead" },
- runtimeConfig: { heartbeat: { enabled: false } },
- }),
- });
- await route.fulfill({
- status: real.status,
- contentType: "application/json",
- body: await real.text(),
- });
+ // Seed a pre-existing company so the companies list is non-empty when the
+ // wizard launches — the exact condition that reproduced the bounce.
+ const seedRes = await page.request.post("/api/companies", {
+ data: { name: `E2E-Seed-${Date.now()}` },
});
+ expect(seedRes.ok()).toBe(true);
- await page.goto("/onboarding");
+ const companyName = `E2E-TypingIntro-Existing-${Date.now()}`;
+ await runOnboardingWizard(page, companyName);
- // Launcher card path (existing companies) — enter the wizard if the
- // route shows a launcher instead of opening the wizard directly.
- const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
- if (await startBtn.count()) await startBtn.first().click();
-
- // Step 0: front door (skipped when the wizard opens on the create path).
- const frontDoor = page.getByText("Build a new company");
- if (await frontDoor.count()) await frontDoor.first().click();
-
- // Step 1: company name.
- await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
- await page.getByRole("button", { name: /^Next/ }).click();
-
- // Step 2: mission (direct path default).
- await page
- .getByPlaceholder("What is your team trying to achieve?")
- .fill(MISSION);
- await page.getByRole("button", { name: /Confirm mission/ }).click();
-
- // Step 3: lead name (prefilled) → Next.
- await page.waitForSelector('input[placeholder="Chief of staff"]', {
- timeout: 15_000,
- });
- await page.getByRole("button", { name: /^Next/ }).click();
-
- // Step 4: adapter (claude_local default); heartbeat is intercepted.
- await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
-
- // Step 5: review → Get started creates the first task and opens dashboard.
- const getStarted = page.getByRole("button", { name: /Get started/ });
- await getStarted.waitFor({ timeout: 20_000 });
- await getStarted.click();
-
- await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
-
- const companiesRes = await page.request.get("/api/companies");
- expect(companiesRes.ok()).toBe(true);
- const companies = await companiesRes.json();
- const company = companies.find((candidate: { name: string }) => candidate.name === COMPANY_NAME);
- expect(company).toBeTruthy();
-
- const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
- expect(issuesRes.ok()).toBe(true);
- const issues = await issuesRes.json();
- const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE);
- expect(firstTask).toBeTruthy();
- await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
+ await expectLandsOnFirstTaskWithoutDashboardBounce(page);
+ await assertFirstTaskExists(page, companyName);
});
});
diff --git a/tests/e2e/helpers/onboarding-landing.ts b/tests/e2e/helpers/onboarding-landing.ts
new file mode 100644
index 00000000000..3d3700502c2
--- /dev/null
+++ b/tests/e2e/helpers/onboarding-landing.ts
@@ -0,0 +1,80 @@
+import { expect, type Page } from "@playwright/test";
+
+/**
+ * Regression guard for PAP-404: onboarding must land the user on the newly
+ * created first-task detail page and must NOT bounce to the company dashboard.
+ *
+ * The bounce is a navigation race: the wizard `navigate(/…/issues/…)` used to
+ * be clobbered by `useCompanyPageMemory`, which restores a company's remembered
+ * page (falling back to `/dashboard`) on a non-`route_sync` selection change.
+ *
+ * react-router-dom drives client-side navigation through the History API, so a
+ * Playwright `framenavigated` listener never fires for these SPA transitions.
+ * Instead we hook `history.pushState`/`replaceState` before the app boots and
+ * record every path the router visits — a `/dashboard` bounce (even a transient
+ * one that later self-corrects) leaves a trace in the log.
+ */
+
+const ISSUE_URL = /\/issues\/[^/]+$/;
+const DASHBOARD_PATH = /\/dashboard(\/|$)/;
+
+/**
+ * Install a History-API tap that records every client-side path change into
+ * `window.__navLog`. Must be called BEFORE the first `page.goto` so the init
+ * script is present when the SPA boots.
+ */
+export async function instrumentNavLog(page: Page): Promise {
+ await page.addInitScript(() => {
+ const w = window as unknown as { __navLog?: string[] };
+ if (w.__navLog) return;
+ const log: string[] = [];
+ w.__navLog = log;
+ const record = () => log.push(window.location.pathname);
+ const wrap = unknown>(fn: T): T =>
+ function (this: unknown, ...args: never[]) {
+ const result = fn.apply(this, args);
+ record();
+ return result;
+ } as unknown as T;
+ history.pushState = wrap(history.pushState.bind(history));
+ history.replaceState = wrap(history.replaceState.bind(history));
+ window.addEventListener("popstate", record);
+ record();
+ });
+}
+
+async function readNavLog(page: Page): Promise {
+ return page.evaluate(
+ () => (window as unknown as { __navLog?: string[] }).__navLog ?? [],
+ );
+}
+
+/**
+ * Assert the wizard settled on the first-task detail page and never rested on
+ * (or bounced through) the dashboard.
+ *
+ * Fails if a dashboard bounce is reintroduced: the History-API log will contain
+ * a `/dashboard` entry and/or the settled URL will not be the issue page.
+ */
+export async function expectLandsOnFirstTaskWithoutDashboardBounce(
+ page: Page,
+): Promise {
+ // The wizard's launch handler does async work (hire + create issue) before
+ // navigating, so give reaching the issue page a generous budget.
+ await expect(page).toHaveURL(ISSUE_URL, { timeout: 30_000 });
+ const settledUrl = page.url();
+
+ // Short settle window: the page-memory effect fires on the selection change
+ // that accompanies the launch navigate, so any bounce lands within ~1s. If
+ // the URL is still the issue after this window it has genuinely settled.
+ await page.waitForTimeout(1_500);
+ expect(page.url(), "onboarding bounced away from the first task").toBe(settledUrl);
+ await expect(page).toHaveURL(ISSUE_URL);
+
+ const navLog = await readNavLog(page);
+ const bounced = navLog.filter((path) => DASHBOARD_PATH.test(path));
+ expect(
+ bounced,
+ `onboarding navigated to the dashboard (nav log: ${navLog.join(" -> ")})`,
+ ).toEqual([]);
+}
diff --git a/tests/e2e/nux-phase4-screenshots.spec.ts b/tests/e2e/nux-phase4-screenshots.spec.ts
index 4c3a3481bea..c872f5712bd 100644
--- a/tests/e2e/nux-phase4-screenshots.spec.ts
+++ b/tests/e2e/nux-phase4-screenshots.spec.ts
@@ -66,7 +66,7 @@ test.describe("NUX Phase 4 visual QA", () => {
await createCard.first().click();
}
await expect(
- page.getByRole("heading", { name: "Name your company" }),
+ page.getByRole("heading", { name: "Name your organization" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics");
await page.screenshot({ path: shot("02-create-name.png") });
@@ -120,7 +120,7 @@ test.describe("NUX Phase 4 visual QA", () => {
await page.getByRole("button", { name: /Add agents to your org/ }).click();
// The grow path shares step 1 (company name) before its step-2 intake.
await expect(
- page.getByRole("heading", { name: "Name your company" }),
+ page.getByRole("heading", { name: "Name your organization" }),
).toBeVisible({ timeout: 10_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics Grow");
await page.getByRole("button", { name: /^Next/ }).click();
diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts
index eb46e76e50a..a29078a724c 100644
--- a/tests/e2e/onboarding.spec.ts
+++ b/tests/e2e/onboarding.spec.ts
@@ -6,7 +6,7 @@ import { test, expect } from "@playwright/test";
* The wizard now opens on a front door (path picker) and the "Create a new
* company" path runs:
* Step 0 — Front door (Create a new company / Level up existing)
- * Step 1a — Name your company
+ * Step 1a — Name your organization
* Step 1b — Define your mission (direct or guided)
* Step 2 — Hire your team lead (adapter picker)
* Step 3+ — Launch celebration → CEO chat → hiring plan → orientation
@@ -53,9 +53,9 @@ test.describe("Onboarding wizard", () => {
await createCard.first().click();
}
- // Step 1 — Name your company.
+ // Step 1 — Name your organization.
await expect(
- page.getByRole("heading", { name: "Name your company" }),
+ page.getByRole("heading", { name: "Name your organization" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
diff --git a/tests/e2e/planning-mode-visual-verification.spec.ts b/tests/e2e/planning-mode-visual-verification.spec.ts
index 65be76312de..398fe24f0d4 100644
--- a/tests/e2e/planning-mode-visual-verification.spec.ts
+++ b/tests/e2e/planning-mode-visual-verification.spec.ts
@@ -1,13 +1,19 @@
import { expect, test } from "@playwright/test";
+import {
+ expectLandsOnFirstTaskWithoutDashboardBounce,
+ instrumentNavLog,
+} from "./helpers/onboarding-landing";
const AGENT_NAME = "Chief of staff";
-const TASK_TITLE = "Hire your first engineer and create a hiring plan";
+const TASK_TITLE = "Paperclip onboarding";
test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const timestamp = Date.now();
const companyName = `PAP-3413-${timestamp}`;
const screenshotDir = "test-results/planning-mode";
+ await instrumentNavLog(page);
+
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
@@ -47,7 +53,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const createCard = page.getByRole("button", { name: /Build a new company/ });
if (await createCard.count()) await createCard.first().click();
- await expect(page.getByRole("heading", { name: "Name your company" })).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole("heading", { name: "Name your organization" })).toBeVisible({ timeout: 15_000 });
await page.locator('input[placeholder="Acme Corp"]').fill(companyName);
await page.getByRole("button", { name: /^Next/ }).click();
@@ -62,11 +68,13 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
- await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
+ await page.getByRole("button", { name: /^Connect$/ }).click();
await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Get started/ }).click();
- await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
+ // The wizard now drops the user straight onto the first task's detail page,
+ // and must not bounce through the dashboard (PAP-404).
+ await expectLandsOnFirstTaskWithoutDashboardBounce(page);
const baseOrigin = new URL(page.url()).origin;
const companyRes = await page.request.get(`${baseOrigin}/api/companies`);
@@ -108,11 +116,9 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await page.goto(issuePath);
await expect(page.getByText("Plan mode").first()).toBeVisible();
- await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "planning");
- const desktopPlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle");
+ const desktopPlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(desktopPlanningToggle).toBeVisible();
await expect(desktopPlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
- await expect(desktopPlanningToggle).toHaveAttribute("aria-pressed", "true");
await page.screenshot({
path: `${screenshotDir}/desktop-planning-detail-${timestamp}.png`,
@@ -128,11 +134,9 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
});
await page.goto(issuePath);
- await page.getByTestId("issue-chat-composer-work-mode-toggle").click();
- await page.getByTestId("issue-chat-composer-work-mode-menu-standard").click();
- await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "standard");
- await expect(page.getByTestId("issue-chat-composer-work-mode-toggle")).toHaveAttribute("data-pending-work-mode", "standard");
- await expect(page.getByTestId("issue-chat-composer-work-mode-toggle")).toHaveAttribute("aria-pressed", "false");
+ await page.getByTestId("task-chat-composer-mode").click();
+ await page.getByRole("menuitem", { name: /Agent mode/ }).click();
+ await expect(page.getByTestId("task-chat-composer-mode")).toHaveAttribute("data-pending-work-mode", "standard");
await page.screenshot({
path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`,
fullPage: true,
@@ -142,10 +146,9 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(issuePath);
await expect(page.getByText("Plan mode").first()).toBeVisible();
- const mobilePlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle");
+ const mobilePlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(mobilePlanningToggle).toBeVisible();
await expect(mobilePlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
- await expect(mobilePlanningToggle).toHaveAttribute("aria-pressed", "true");
await page.screenshot({
path: `${screenshotDir}/mobile-planning-detail-${timestamp}.png`,
fullPage: true,
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 3a5c3873232..31a71070861 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -3,7 +3,6 @@ import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n";
import { Layout } from "./components/Layout";
import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate";
-import { TaskChatRedesignGate } from "./components/TaskChatRedesignGate";
import { TaskChatLab } from "./pages/TaskChatLab";
import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate";
import { CasesExperimentalGate } from "./components/CasesExperimentalGate";
@@ -276,13 +275,9 @@ function boardRoutes() {
} />
} />
- {/* Task Chat Redesign dev harness — dev builds only, and additionally
- gated by enableTaskChatRedesign (redirects to /dashboard when the
- flag is off). */}
+ {/* Task chat dev harness — dev builds only. */}
{import.meta.env.DEV ? (
- }>
- } />
-
+ } />
) : null}
} />
} />
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index 729b64bd36b..abb3d149576 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -439,10 +439,10 @@ interface IssueChatThreadProps {
linkedRuns?: IssueChatLinkedRun[];
timelineEvents?: IssueTimelineEvent[];
/**
- * Work-mode switch history from the activity feed. Only the redesigned
- * TaskChatThread consumes this (flag: enableTaskChatRedesign) to tag each
- * agent reply with the mode its request ran under; the legacy thread
- * ignores it.
+ * Work-mode switch history from the activity feed. Only the chat-style
+ * TaskChatThread consumes this to tag each agent reply with the mode its
+ * request ran under; this thread — the classic task view behind
+ * enableClassicTaskInterface — ignores it.
*/
workModeChanges?: IssueWorkModeChange[];
liveRuns?: LiveRunForIssue[];
@@ -513,16 +513,15 @@ interface IssueChatThreadProps {
footer?: ReactNode;
/**
* Issue header content (title row, badges, plugin toolbars) rendered INSIDE
- * the thread's scroll viewport so it scrolls away with the messages. Only the
- * redesigned TaskChatThread consumes this (flag: enableTaskChatRedesign);
- * the legacy thread ignores it — its header stays in the page flow.
+ * the thread's scroll viewport so it scrolls away with the messages. Only
+ * the chat-style TaskChatThread consumes this; this thread ignores it — its
+ * header stays in the page flow.
*/
threadHeader?: ReactNode;
/**
* The task description rendered as the requester's first chat bubble
- * (PAP-375). Only the redesigned TaskChatThread consumes it (flag:
- * enableTaskChatRedesign); the legacy thread ignores it — its description
- * stays in the page header via InlineEditor.
+ * (PAP-375). Only the chat-style TaskChatThread consumes it; this thread
+ * ignores it — its description stays in the page header via InlineEditor.
*/
issueBrief?: TaskChatIssueBrief;
variant?: "full" | "embedded";
diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx
index 40d8a2be4d8..f9ac92b65cc 100644
--- a/ui/src/components/IssueProperties.test.tsx
+++ b/ui/src/components/IssueProperties.test.tsx
@@ -481,7 +481,7 @@ describe("IssueProperties", () => {
it("keeps the Plan tab visible for a planning-mode issue without a plan document", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
- enableTaskChatRedesign: true,
+ enableClassicTaskInterface: false,
});
mockIssuesApi.listInteractions.mockResolvedValue([
{
diff --git a/ui/src/components/IssueThreadInteractionCard.test.tsx b/ui/src/components/IssueThreadInteractionCard.test.tsx
index 2091aeacfd1..2f655bcb017 100644
--- a/ui/src/components/IssueThreadInteractionCard.test.tsx
+++ b/ui/src/components/IssueThreadInteractionCard.test.tsx
@@ -9,6 +9,7 @@ import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import {
pendingAskUserQuestionsInteraction,
+ pendingAskUserQuestionsWithFreeTextOption,
commentExpiredAskUserQuestionsInteraction,
commentExpiredRequestConfirmationInteraction,
declinedToolActionInteraction,
@@ -185,6 +186,104 @@ describe("IssueThreadInteractionCard", () => {
);
});
+ it("reveals an inline field when a free-text option is selected and hides the standalone Other link", async () => {
+ const onSubmitInteractionAnswers = vi.fn(async () => undefined);
+ const host = renderCard({
+ interaction: pendingAskUserQuestionsWithFreeTextOption,
+ onSubmitInteractionAnswers,
+ });
+
+ // A first-class free-text option suppresses the built-in "Other" link.
+ const otherLink = Array.from(host.querySelectorAll("button")).find(
+ (button) => button.textContent === "Other",
+ );
+ expect(otherLink).toBeUndefined();
+
+ // No text field until the free-text option is selected.
+ expect(host.querySelector("textarea")).toBeNull();
+
+ const describeOption = Array.from(host.querySelectorAll('[role="radio"]')).find(
+ (button) => button.textContent?.includes("I'll describe it"),
+ ) as HTMLButtonElement | undefined;
+ expect(describeOption).toBeTruthy();
+
+ await act(async () => {
+ describeOption?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(describeOption?.getAttribute("aria-checked")).toBe("true");
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
+ expect(textarea).toBeTruthy();
+
+ await act(async () => {
+ const valueSetter = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ "value",
+ )?.set;
+ valueSetter?.call(textarea, "Call it Threads");
+ textarea!.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+
+ const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Send answers"),
+ );
+ await act(async () => {
+ submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: "ask_user_questions" }),
+ [
+ {
+ questionId: "surface-name",
+ optionIds: [],
+ otherText: "Call it Threads",
+ },
+ ],
+ );
+ });
+
+ it("renders nothing for a degenerate ask_user_questions card", () => {
+ // A truly unanswerable question: a prompt with no options and no free-text
+ // field, so there is nothing for the user to select or type. Hiding it
+ // strands nothing.
+ const degenerate = {
+ ...pendingAskUserQuestionsInteraction,
+ id: "interaction-questions-degenerate",
+ payload: {
+ version: 1 as const,
+ title: "Placeholder",
+ questions: [
+ {
+ id: "q1",
+ prompt: "Anything?",
+ selectionMode: "single" as const,
+ options: [],
+ },
+ ],
+ },
+ };
+
+ const host = renderCard({
+ interaction: degenerate,
+ onSubmitInteractionAnswers: vi.fn(),
+ });
+
+ // No card wrapper, no title, no controls — the component returns null.
+ expect(host.childElementCount).toBe(0);
+ expect(host.textContent).toBe("");
+ });
+
+ it("still renders a legitimate ask_user_questions card", () => {
+ const host = renderCard({
+ interaction: pendingAskUserQuestionsInteraction,
+ onSubmitInteractionAnswers: vi.fn(),
+ });
+
+ expect(host.childElementCount).toBeGreaterThan(0);
+ expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0);
+ });
+
it("only shows question cancellation when a cancel handler is wired", () => {
const withoutHandler = renderCard({
interaction: pendingAskUserQuestionsInteraction,
@@ -329,32 +428,37 @@ describe("IssueThreadInteractionCard", () => {
expect(host.textContent).toContain("No reason provided.");
});
- it("requires a decline reason when the request confirmation payload asks for one", async () => {
+ it("requires a revision note when the request confirmation payload asks for one", async () => {
const onRejectInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
onRejectInteraction,
});
- const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
- button.textContent?.includes("Request revisions"),
+ // rejectRequiresReason drops the bare Reject: the only send-back path is Revise…
+ expect(Array.from(host.querySelectorAll("button")).some((button) =>
+ button.textContent?.trim() === "Reject",
+ )).toBe(false);
+ const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Revise"),
);
- expect(declineButton).toBeTruthy();
+ expect(reviseButton).toBeTruthy();
await act(async () => {
- declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
- button.textContent?.includes("Request revisions"),
- ).at(-1);
- expect(saveButton?.hasAttribute("disabled")).toBe(false);
+ const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Send revision"),
+ );
+ expect(sendButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
- saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
- expect(host.textContent).toContain("A decline reason is required.");
+ expect(host.textContent).toContain("Add a note describing the changes you want.");
+ expect(onRejectInteraction).not.toHaveBeenCalled();
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
@@ -368,12 +472,12 @@ describe("IssueThreadInteractionCard", () => {
valueSetter?.call(textarea, "Needs a smaller phase split");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
- const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
- button.textContent?.includes("Request revisions"),
- ).at(-1);
- expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false);
+ const enabledSendButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Send revision"),
+ );
+ expect(enabledSendButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
- enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ enabledSendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onRejectInteraction).toHaveBeenCalledWith(
@@ -403,6 +507,35 @@ describe("IssueThreadInteractionCard", () => {
);
});
+ it("standardizes the bare-reject button to Reject even when the payload carries a legacy rejectLabel", () => {
+ const host = renderCard({
+ interaction: {
+ ...pendingRequestConfirmationInteraction,
+ payload: {
+ ...pendingRequestConfirmationInteraction.payload,
+ // Onboarding/plan-approval interactions are still seeded with the
+ // legacy "Request changes" reject label; it must not leak into the CTA.
+ rejectLabel: "Request changes",
+ rejectRequiresReason: false,
+ },
+ },
+ onAcceptInteraction: vi.fn(async () => undefined),
+ onRejectInteraction: vi.fn(async () => undefined),
+ });
+
+ const labels = Array.from(host.querySelectorAll("button")).map((button) =>
+ button.textContent?.trim(),
+ );
+
+ // Canonical plan-approval grammar, right→left: Approve · Revise… · Reject.
+ // "Revise…" already carries the send-back-with-notes path, so a distinct
+ // "Request changes" word is redundant and must not render.
+ expect(labels).toContain("Reject");
+ expect(labels).toContain("Revise…");
+ expect(labels.some((label) => label?.includes("Approve"))).toBe(true);
+ expect(host.textContent).not.toContain("Request changes");
+ });
+
it("does not expose continuation wake policy labels in the card header", () => {
const host = renderCard({
interaction: {
@@ -450,8 +583,10 @@ describe("IssueThreadInteractionCard", () => {
onRejectInteraction,
});
+ // The bare-reject button always renders the canonical "Reject", not the
+ // payload's "Keep it" — ConfirmationActionRow no longer honors the override.
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
- button.textContent?.includes("Keep it"),
+ button.textContent?.trim() === "Reject",
);
expect(declineButton).toBeTruthy();
@@ -543,11 +678,11 @@ describe("IssueThreadInteractionCard", () => {
onUploadImage,
});
- const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
- button.textContent?.includes("Request revisions"),
+ const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Revise"),
);
await act(async () => {
- declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const attachButton = Array.from(host.querySelectorAll("button")).find((button) =>
@@ -570,11 +705,11 @@ describe("IssueThreadInteractionCard", () => {
});
expect(onUploadImage).toHaveBeenCalledTimes(1);
- const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
- button.textContent?.includes("Request revisions"),
- ).at(-1);
+ const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Send revision"),
+ );
await act(async () => {
- saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onRejectInteraction).toHaveBeenCalledWith(
@@ -790,19 +925,19 @@ describe("IssueThreadInteractionCard tool-action card", () => {
expect(host.textContent).not.toContain("Technical details");
});
- it("renders the agents-may-resolve policy badge and addressee chip", () => {
+ it("renders the addressee chip without the removed policy badge", () => {
const host = renderCard({
interaction: agentAddressedRequestConfirmationInteraction,
});
- const policyBadge = host.querySelector('[data-testid="interaction-policy-badge"]');
- expect(policyBadge?.textContent).toContain("Agents may resolve");
+ // PAP-440: the "Agents may resolve" policy badge was pure noise — never rendered.
+ expect(host.querySelector('[data-testid="interaction-policy-badge"]')).toBeNull();
const addresseeBadge = host.querySelector('[data-testid="interaction-addressee-badge"]');
expect(addresseeBadge?.textContent).toContain("For ");
});
- it("omits the policy and addressee badges for a board-only interaction", () => {
+ it("omits the addressee badge for a board-only interaction", () => {
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
});
diff --git a/ui/src/components/IssueThreadInteractionCard.tsx b/ui/src/components/IssueThreadInteractionCard.tsx
index d7054842686..804d2382ebe 100644
--- a/ui/src/components/IssueThreadInteractionCard.tsx
+++ b/ui/src/components/IssueThreadInteractionCard.tsx
@@ -1,6 +1,6 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { Agent } from "@paperclipai/shared";
-import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Users, Wrench, X, XCircle } from "lucide-react";
+import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import { formatAssigneeUserLabel } from "../lib/assignees";
import {
@@ -10,6 +10,7 @@ import {
getCheckboxConfirmationSelectedLabels,
getItemVerdictProgress,
getQuestionAnswerLabels,
+ shouldHideInteractionCard,
normalizeRequestConfirmationTargetHref,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
@@ -989,8 +990,16 @@ function AskUserQuestionsCard({
),
);
- function toggleOption(questionId: string, optionId: string, selectionMode: "single" | "multi") {
- if (optionId === OTHER_ANSWER_ID) {
+ function toggleOption(
+ questionId: string,
+ optionId: string,
+ selectionMode: "single" | "multi",
+ isFreeText = false,
+ ) {
+ // A free-text option is a first-class version of the built-in "Other"
+ // affordance: selecting it reveals the inline text field and its typed
+ // value is submitted as the question's `otherText`.
+ if (optionId === OTHER_ANSWER_ID || isFreeText) {
setOtherActiveQuestions((current) => ({
...current,
[questionId]: !current[questionId],
@@ -1064,7 +1073,11 @@ function AskUserQuestionsCard({
{interaction.status === "pending" ? (
- {questions.map((question, index) => (
+ {questions.map((question, index) => {
+ const hasFreeTextOption = question.options.some(
+ (option) => option.freeText === true,
+ );
+ return (
- {question.options.map((option) => (
-
- toggleOption(question.id, option.id, question.selectionMode)}
- />
- ))}
+ {question.options.map((option) => {
+ const isFreeText = option.freeText === true;
+ const optionSelected = isFreeText
+ ? otherActiveQuestions[question.id] === true
+ : (draftAnswers[question.id] ?? []).includes(option.id);
+ return (
+
+
+ toggleOption(question.id, option.id, question.selectionMode, isFreeText)}
+ />
+ {isFreeText && optionSelected ? (
+
+ );
+ })}
-
- toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
- >
- Other
-
- {otherActiveQuestions[question.id] ? (
-
- ))}
+ );
+ })}
@@ -1878,6 +1923,204 @@ function RequestToolActionCard({
);
}
+/**
+ * The single approval grammar shared by every plan / task-approval card
+ * (PAP-418): **Approve · Revise… · Reject**. "Revise…" reveals an attached text
+ * field for the changes you want (the former "decline with a reason" path);
+ * bare "Reject" sends the work back with no note. These are the default words —
+ * producers may still override the accept/reject labels for domain-specific
+ * confirmations (e.g. "Delete selected"), but the shape stays consistent.
+ */
+const CONFIRMATION_APPROVE_LABEL = "Approve";
+const CONFIRMATION_REVISE_LABEL = "Revise…";
+const CONFIRMATION_REJECT_LABEL = "Reject";
+
+/**
+ * The one action control every confirmation card renders (PAP-418), collapsing
+ * what used to be a two-button card plus a separate sticky Plan-pane bar into a
+ * single consistent surface. Producer flags tune which affordances appear:
+ * `allowDeclineReason: false` drops the Revise… path so only Approve/Reject
+ * remain; `rejectRequiresReason: true` drops the bare Reject so every rejection
+ * carries a note. The revise text stays attached to the card.
+ */
+function ConfirmationActionRow({
+ resetKey,
+ approveLabel,
+ reviseLabel = CONFIRMATION_REVISE_LABEL,
+ rejectLabel,
+ approveVariant = "default",
+ primaryActionOnRight = false,
+ allowRevise,
+ rejectRequiresReason,
+ reasonPlaceholder,
+ working,
+ actionError,
+ approveDisabled = false,
+ canApprove,
+ canReject,
+ onApprove,
+ onReject,
+ composeReason,
+ extraReasonSatisfied = false,
+ revisePanelChildren,
+}: {
+ /** Changing this (interaction id + status) collapses the revise panel and
+ * clears its draft text — the row is reused across interaction updates. */
+ resetKey: string;
+ approveLabel: string;
+ reviseLabel?: string;
+ rejectLabel: string;
+ approveVariant?: React.ComponentProps
["variant"];
+ primaryActionOnRight?: boolean;
+ allowRevise: boolean;
+ rejectRequiresReason: boolean;
+ reasonPlaceholder: string;
+ working: "accept" | "reject" | null;
+ actionError: string | null;
+ approveDisabled?: boolean;
+ canApprove: boolean;
+ canReject: boolean;
+ onApprove: () => void;
+ onReject: (reason: string | undefined) => void;
+ /** Compose the final reject reason from the typed text (plan cards append
+ * screenshot markdown here). */
+ composeReason?: (text: string) => string | undefined;
+ /** A required reason is already satisfied by an attachment (e.g. screenshots),
+ * so an empty text box should not block sending the revision. */
+ extraReasonSatisfied?: boolean;
+ /** Extra affordances rendered inside the revise panel (e.g. screenshot attach). */
+ revisePanelChildren?: ReactNode;
+}) {
+ const [revising, setRevising] = useState(false);
+ const [reason, setReason] = useState("");
+ const [attempted, setAttempted] = useState(false);
+
+ useEffect(() => {
+ setRevising(false);
+ setReason("");
+ setAttempted(false);
+ }, [resetKey]);
+
+ const trimmed = reason.trim();
+ const reasonMissing = rejectRequiresReason && trimmed.length === 0 && !extraReasonSatisfied;
+
+ function submitRevision() {
+ setAttempted(true);
+ if (!canReject || reasonMissing) return;
+ onReject(composeReason ? composeReason(reason) : trimmed || undefined);
+ }
+
+ return (
+
+
+
+ {working === "accept" ? (
+ <>
+
+ Approving…
+ >
+ ) : (
+ approveLabel
+ )}
+
+ {allowRevise ? (
+ {
+ setAttempted(false);
+ setRevising((current) => !current);
+ }}
+ >
+ {reviseLabel}
+
+ ) : null}
+ {!rejectRequiresReason ? (
+ onReject(undefined)}
+ >
+ {working === "reject" && !revising ? (
+ <>
+
+ Rejecting…
+ >
+ ) : (
+ rejectLabel
+ )}
+
+ ) : null}
+
+
+ {revising ? (
+
+
setReason(event.target.value)}
+ placeholder={reasonPlaceholder}
+ aria-invalid={attempted && reasonMissing}
+ className={cn(
+ "min-h-24 bg-background text-sm",
+ attempted && reasonMissing && "border-rose-500 focus-visible:ring-rose-500/25",
+ )}
+ />
+ {attempted && reasonMissing ? (
+ Add a note describing the changes you want.
+ ) : null}
+ {revisePanelChildren}
+
+ {
+ setRevising(false);
+ setAttempted(false);
+ }}
+ >
+ Cancel
+
+
+ {working === "reject" ? (
+ <>
+
+ Sending…
+ >
+ ) : (
+ "Send revision"
+ )}
+
+
+
+ ) : null}
+
+ {actionError ? (
+
+ {actionError}
+
+ ) : null}
+
+ );
+}
+
function RequestConfirmationCard({
interaction,
isPlan = false,
@@ -1900,37 +2143,28 @@ function RequestConfirmationCard({
onUploadImage?: (file: File) => Promise;
externalReferences?: MarkdownExternalReferenceMap;
}) {
- const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
- const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
- const [rejectAttempted, setRejectAttempted] = useState(false);
const [actionError, setActionError] = useState(null);
const [shots, setShots] = useState<{ name: string; url: string }[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState(null);
const fileInputRef = useRef(null);
- // Screenshots ride along in the decline reason as markdown image refs so the
+ // Screenshots ride along in the revise note as markdown image refs so the
// board can attach images when sending a plan back — no schema change needed.
const allowScreenshots = isPlan && Boolean(onUploadImage);
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
- const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
- const trimmedRejectReason = rejectReason.trim();
- const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0 || shots.length > 0;
- const declineReasonInvalid = rejectRequiresReason && !canReject;
- const declineReasonPlaceholder =
+ const allowRevise = interaction.payload.allowDeclineReason !== false;
+ const reasonPlaceholder =
interaction.payload.declineReasonPlaceholder
?? (interaction.payload.acceptLabel === "Approve plan"
? "Optional: what would you like revised?"
: "Optional: tell the agent what you'd change.");
useEffect(() => {
- setRejectReason(interaction.result?.reason ?? "");
- setRejectAttempted(false);
setActionError(null);
setShots([]);
setUploadError(null);
if (interaction.status !== "pending") {
- setRejecting(false);
setWorking(null);
}
}, [interaction.id, interaction.result?.reason, interaction.status]);
@@ -1954,11 +2188,11 @@ function RequestConfirmationCard({
}
}
- function composeReason() {
- const text = trimmedRejectReason;
- if (shots.length === 0) return text || undefined;
+ function composeReason(text: string) {
+ const trimmed = text.trim();
+ if (shots.length === 0) return trimmed || undefined;
const images = shots.map((s) => ``).join("\n");
- return [text, images].filter(Boolean).join("\n\n");
+ return [trimmed, images].filter(Boolean).join("\n\n");
}
async function handleAccept() {
@@ -1974,14 +2208,12 @@ function RequestConfirmationCard({
}
}
- async function handleReject() {
- setRejectAttempted(true);
- if (!onRejectInteraction || !canReject) return;
+ async function handleReject(reason: string | undefined) {
+ if (!onRejectInteraction) return;
setWorking("reject");
setActionError(null);
try {
- await onRejectInteraction(interaction, composeReason());
- setRejecting(false);
+ await onRejectInteraction(interaction, reason);
} catch {
setActionError("Try again");
} finally {
@@ -2009,161 +2241,89 @@ function RequestConfirmationCard({
) : null}
{interaction.status === "pending" ? (
-
-
- void handleAccept()}
- >
- {working === "accept" ? (
- <>
-
- Confirming...
- >
- ) : (
- interaction.payload.acceptLabel ?? "Confirm"
- )}
-
- {
- if (!allowDeclineReason) {
- void handleReject();
- return;
- }
- setRejectAttempted(false);
- setRejecting((current) => !current);
- }}
- >
- {interaction.payload.rejectLabel ?? "Decline"}
-
-
-
- {rejecting ? (
-
-
setRejectReason(event.target.value)}
- placeholder={declineReasonPlaceholder}
- aria-invalid={rejectAttempted && declineReasonInvalid}
- className={cn(
- "min-h-24 bg-background text-sm",
- rejectAttempted && declineReasonInvalid
- && "border-rose-500 focus-visible:ring-rose-500/25",
- )}
- />
- {rejectAttempted && declineReasonInvalid ? (
- A decline reason is required.
- ) : null}
- {allowScreenshots ? (
-
- {shots.length > 0 ? (
-
- {shots.map((shot, index) => (
-
void handleAccept()}
+ onReject={(reason) => void handleReject(reason)}
+ composeReason={composeReason}
+ extraReasonSatisfied={shots.length > 0}
+ revisePanelChildren={
+ allowScreenshots ? (
+
+ {shots.length > 0 ? (
+
+ {shots.map((shot, index) => (
+
+
+
+ setShots((current) => current.filter((_, i) => i !== index))
+ }
>
-
-
- setShots((current) => current.filter((_, i) => i !== index))
- }
- >
-
-
-
- ))}
-
- ) : null}
-
{
- void handleAddScreenshots(event.target.value ? event.target.files : null);
- event.target.value = "";
- }}
- />
-
fileInputRef.current?.click()}
- >
- {uploading ? (
- <>
-
- Uploading...
- >
- ) : (
- <>
-
- Attach screenshots
- >
- )}
-
- {uploadError ? (
-
{uploadError}
- ) : null}
-
- ) : null}
-
- {
- setRejecting(false);
- setRejectAttempted(false);
+
+
+
+ ))}
+
+ ) : null}
+
{
+ void handleAddScreenshots(event.target.value ? event.target.files : null);
+ event.target.value = "";
}}
- >
- Cancel decline
-
+ />
void handleReject()}
+ disabled={working !== null || uploading}
+ onClick={() => fileInputRef.current?.click()}
>
- {working === "reject" ? (
+ {uploading ? (
<>
- Saving...
+ Uploading...
>
) : (
- interaction.payload.rejectLabel ?? "Decline"
+ <>
+
+ Attach screenshots
+ >
)}
+ {uploadError ? (
+
{uploadError}
+ ) : null}
-
- ) : null}
-
- {actionError ? (
-
- {actionError}
-
- ) : null}
-
+ ) : null
+ }
+ />
) : (
)}
@@ -2293,11 +2453,13 @@ function CheckboxOptionRow({
function RequestCheckboxConfirmationCard({
interaction,
+ primaryActionOnRight = false,
onAcceptInteraction,
onRejectInteraction,
externalReferences,
}: {
interaction: RequestCheckboxConfirmationInteraction;
+ primaryActionOnRight?: boolean;
onAcceptInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
selectedClientKeys: undefined,
@@ -2324,10 +2486,7 @@ function RequestCheckboxConfirmationCard({
);
const [selectedOptionIds, setSelectedOptionIds] = useState
>(() => new Set(defaultSelected));
- const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
- const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
- const [rejectAttempted, setRejectAttempted] = useState(false);
const [acceptAttempted, setAcceptAttempted] = useState(false);
const [actionError, setActionError] = useState(null);
@@ -2335,22 +2494,16 @@ function RequestCheckboxConfirmationCard({
useEffect(() => {
setSelectedOptionIds(new Set(defaultSelected));
- setRejectReason(interaction.result?.reason ?? "");
- setRejectAttempted(false);
setAcceptAttempted(false);
setActionError(null);
if (interaction.status !== "pending") {
- setRejecting(false);
setWorking(null);
}
}, [interaction.id, interaction.status, interaction.result?.reason, defaultSelected, optionSeed]);
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
- const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
- const trimmedRejectReason = rejectReason.trim();
- const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0;
- const declineReasonInvalid = rejectRequiresReason && !canReject;
- const declineReasonPlaceholder =
+ const allowRevise = interaction.payload.allowDeclineReason !== false;
+ const reasonPlaceholder =
interaction.payload.declineReasonPlaceholder ?? "Optional: tell the agent what you'd change.";
const selectedCount = selectedOptionIds.size;
@@ -2405,14 +2558,12 @@ function RequestCheckboxConfirmationCard({
}
}
- async function handleReject() {
- setRejectAttempted(true);
- if (!onRejectInteraction || !canReject) return;
+ async function handleReject(reason: string | undefined) {
+ if (!onRejectInteraction) return;
setWorking("reject");
setActionError(null);
try {
- await onRejectInteraction(interaction, trimmedRejectReason || undefined);
- setRejecting(false);
+ await onRejectInteraction(interaction, reason);
} catch {
setActionError("Try again");
} finally {
@@ -2503,91 +2654,21 @@ function RequestCheckboxConfirmationCard({
{validationMessage}
) : null}
-
- void handleAccept()}
- >
- {working === "accept" ? (
- <>
-
- Confirming...
- >
- ) : (
- interaction.payload.acceptLabel ?? "Confirm selected"
- )}
-
- {
- if (!allowDeclineReason) {
- void handleReject();
- return;
- }
- setRejectAttempted(false);
- setRejecting((current) => !current);
- }}
- >
- {interaction.payload.rejectLabel ?? "Request changes"}
-
-
-
- {rejecting ? (
-
-
setRejectReason(event.target.value)}
- placeholder={declineReasonPlaceholder}
- aria-invalid={rejectAttempted && declineReasonInvalid}
- className={cn(
- "min-h-24 bg-background text-sm",
- rejectAttempted && declineReasonInvalid
- && "border-rose-500 focus-visible:ring-rose-500/25",
- )}
- />
- {rejectAttempted && declineReasonInvalid ? (
- A reason is required.
- ) : null}
-
- {
- setRejecting(false);
- setRejectAttempted(false);
- }}
- >
- Cancel
-
- void handleReject()}
- >
- {working === "reject" ? (
- <>
-
- Saving...
- >
- ) : (
- interaction.payload.rejectLabel ?? "Request changes"
- )}
-
-
-
- ) : null}
-
- {actionError ? (
-
- {actionError}
-
- ) : null}
+ void handleAccept()}
+ onReject={(reason) => void handleReject(reason)}
+ />
);
@@ -3096,6 +3177,16 @@ export function IssueThreadInteractionCard({
onUploadImage,
externalReferences,
}: IssueThreadInteractionCardProps) {
+ // Single enforcement point (PAP-424, plan from PAP-420; extended by PAP-437):
+ // a card that should never be drawn — a degenerate `ask_user_questions`
+ // (placeholder junk like the onboarding `Test / A` card, no genuine question)
+ // or a stale sibling the server auto-expired when its creator posted a newer
+ // question (`superseded_by_newer_interaction`). Every render site (both thread
+ // backbones + the attention resolver) routes through this component, so
+ // suppressing here suppresses it everywhere at once. The interaction is still
+ // created and stored server-side; only the render is suppressed. Composition
+ // sites additionally filter it so no empty slot lingers.
+ if (shouldHideInteractionCard(interaction)) return null;
const isPlan = isPlanConfirmation(interaction);
const isToolAction =
interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
@@ -3152,8 +3243,6 @@ export function IssueThreadInteractionCard({
: null;
// P4: audit-visible distinction between agent and human resolution.
const resolvedByAgent = Boolean(interaction.resolvedByAgentId);
- // P2: agents may resolve when the governance-capped policy allows it.
- const agentsMayResolve = interaction.effectiveResolverPolicy === "board_or_agents";
// P3: interactions directed at a specific agent addressee.
const addresseeLabel = interaction.addresseeAgentId
? resolveActorLabel({
@@ -3183,23 +3272,6 @@ export function IssueThreadInteractionCard({
/
{statusText}
- {agentsMayResolve ? (
-
-
-
-
- Agents may resolve
-
-
-
- Governance allows an assigned agent to resolve this interaction without waiting for the board.
-
-
- ) : null}
{addresseeLabel ? (
@@ -3275,6 +3347,7 @@ export function IssueThreadInteractionCard({
) : interaction.kind === "request_checkbox_confirmation" ? (
= 3 && step <= 5 && (
-
+
{step === 5 ? (
@@ -832,7 +862,7 @@ export function OnboardingWizard() {
{step === 3
- ? "Create your team lead"
+ ? "Create your first agent"
: step === 4
? "Connect a model"
: "Review"}
@@ -840,40 +870,47 @@ export function OnboardingWizard() {
{step === 3 ? (
<>
- Name your lead. They'll help drive{" "}
+ They'll help drive{" "}
{companyName} {" "}
toward its mission. We default to{" "}
- Chief of staff —
- rename it to anything you like.
+ Chief of staff .
+ Rename it to anything you like.
>
) : step === 4 ? (
<>Pick the adapter and model your lead will run on, then check the environment.>
) : (
- <>Everything's set up — your team lead is online and ready to work.>
+ <>Your first agent is online and ready to work.>
)}
-
+
-
- {step === 3 ? (
- "an empty slot for an agent"
- ) : step === 4 ? (
- "your team lead, taking shape"
- ) : (
- <>
- {agentName} {" "}
- is online and ready to work!
- >
- )}
-
+ {step !== 3 && (
+
+ {step === 4 ? (
+ "your team lead, taking shape"
+ ) : (
+ {agentName}
+ )}
+
+ )}
)}
@@ -973,9 +1010,9 @@ export function OnboardingWizard() {
-
Name your company
+
Name your organization
- What should we call your company?
+ What should we call your team or company?
@@ -988,7 +1025,7 @@ export function OnboardingWizard() {
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
- Company name
+ Name
{/* Mission path selector */}
-
+
How would you like to define your mission?
@@ -1202,13 +1239,6 @@ export function OnboardingWizard() {
You can always change your mission later in settings.
)}
-
- setStep(1)}
- >
- ← Change company name
-
)}
@@ -1590,7 +1620,7 @@ export function OnboardingWizard() {
{/* Review checklist — everything that's now set up */}
{[
- { label: "Company name", done: Boolean(companyName.trim()) },
+ { label: "Organization name", done: Boolean(companyName.trim()) },
{ label: "Mission", done: Boolean(companyGoal.trim()) },
{ label: "Agent created", done: Boolean(createdAgentId) },
{ label: "Model connected", done: Boolean(createdAgentId) },
@@ -1612,15 +1642,6 @@ export function OnboardingWizard() {
))}
-
- {companyGoal.trim() && (
-
- "{companyGoal}"
-
- )}
-
- We'll create the first task for {agentName} and take you to the dashboard.
-
)}
@@ -1695,7 +1716,7 @@ export function OnboardingWizard() {
) : (
)}
- {loading ? "Bringing to life..." : "Give it a heartbeat"}
+ {loading ? "Connecting..." : "Connect"}
)}
{step === 5 && (
diff --git a/ui/src/components/PropertiesPanel.test.tsx b/ui/src/components/PropertiesPanel.test.tsx
index 3e0ecde97ef..7a85b1b27a4 100644
--- a/ui/src/components/PropertiesPanel.test.tsx
+++ b/ui/src/components/PropertiesPanel.test.tsx
@@ -72,10 +72,10 @@ describe("PropertiesPanel", () => {
vi.clearAllMocks();
});
- describe("flag off (current behavior)", () => {
+ describe("classic task interface on (legacy panel)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
- enableTaskChatRedesign: false,
+ enableClassicTaskInterface: true,
});
});
@@ -98,10 +98,10 @@ describe("PropertiesPanel", () => {
});
});
- describe("flag on (task chat redesign)", () => {
+ describe("classic task interface off (default resizable pane)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
- enableTaskChatRedesign: true,
+ enableClassicTaskInterface: false,
});
});
diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx
index 6cad68da0ef..6a0e684a448 100644
--- a/ui/src/components/PropertiesPanel.tsx
+++ b/ui/src/components/PropertiesPanel.tsx
@@ -1,18 +1,18 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Maximize2, Minimize2, X } from "lucide-react";
import { usePanel } from "../context/PanelContext";
-import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
+import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
export function PropertiesPanel() {
const { panelContent, panelVisible, setPanelVisible } = usePanel();
- const { enabled: redesignEnabled } = useTaskChatRedesignEnabled();
+ const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
if (!panelContent) return null;
- if (!redesignEnabled) {
+ if (classicTaskInterfaceEnabled) {
return (
{
});
await flushReact();
- expect(document.body.textContent).toContain("Create new company...");
+ expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).not.toContain("Add company...");
act(() => {
@@ -281,9 +281,9 @@ describe("SidebarCompanyMenu", () => {
expect(document.body.textContent).toContain("Edit");
expect(document.body.textContent).toContain("Strata");
expect(document.body.textContent).toContain("ANA");
- expect(document.body.textContent).toContain("Create new company...");
+ expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
- expect(document.body.textContent).toContain("Company settings");
+ expect(document.body.textContent).not.toContain("Company settings");
expect(document.body.textContent).toContain("Sign out");
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
@@ -412,7 +412,7 @@ describe("SidebarCompanyMenu", () => {
await openMenu("Open Acme Labs company switcher");
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
- .find((element) => element.textContent?.includes("Create new company..."));
+ .find((element) => element.textContent?.includes("Create new organization..."));
expect(createItem).toBeTruthy();
act(() => {
@@ -494,7 +494,7 @@ describe("SidebarCompanyMenu", () => {
expect(document.body.textContent).toContain("Switch organization");
expect(document.body.textContent).toContain("Create new organization...");
- expect(document.body.textContent).toContain("Organization settings");
+ expect(document.body.textContent).not.toContain("Organization settings");
expect(document.body.textContent).not.toContain("Switch company");
expect(document.body.textContent).not.toContain("Create new company...");
expect(document.body.textContent).not.toContain("Company settings");
diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx
index 714c050fae3..7ae98991591 100644
--- a/ui/src/components/SidebarCompanyMenu.tsx
+++ b/ui/src/components/SidebarCompanyMenu.tsx
@@ -6,7 +6,6 @@ import {
GripVertical,
LogOut,
Plus,
- Settings,
UserPlus,
} from "lucide-react";
import {
@@ -309,7 +308,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
if (createStackUrl) navigateTopLevel(createStackUrl);
return;
}
- openOnboarding();
+ // Skip the front-door "how would you like to get started?" choice and land
+ // directly on "Name your organization" — this entry point is unambiguously
+ // "create a new company" (PAP-431).
+ openOnboarding({ initialStep: 1 });
}
const handleDragEnd = useCallback(
@@ -451,7 +453,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
disabled={isEditingOrder}
>
- {isCloud ? "Create new organization..." : "Create new company..."}
+ Create new organization...
>
@@ -473,21 +475,6 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
-
- {
- if (isEditingOrder) {
- event.preventDefault();
- return;
- }
- closeNavigationChrome();
- }}
- >
-
- {isCloud ? "Organization settings" : "Company settings"}
-
-
{session?.session ? (
<>
diff --git a/ui/src/components/TaskChatRedesignGate.test.tsx b/ui/src/components/TaskChatRedesignGate.test.tsx
deleted file mode 100644
index 478d3343612..00000000000
--- a/ui/src/components/TaskChatRedesignGate.test.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-// @vitest-environment jsdom
-
-import { flushSync } from "react-dom";
-import { createRoot, type Root } from "react-dom/client";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { TaskChatRedesignGate } from "./TaskChatRedesignGate";
-import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled";
-
-const mockInstanceSettingsApi = vi.hoisted(() => ({
- getExperimental: vi.fn(),
-}));
-
-vi.mock("@/api/instanceSettings", () => ({
- instanceSettingsApi: mockInstanceSettingsApi,
-}));
-
-vi.mock("@/lib/router", () => ({
- Navigate: ({ to, replace }: { to: string; replace?: boolean }) => (
-
- ),
- Outlet: () => gated content
,
-}));
-
-async function flushReact() {
- for (let index = 0; index < 5; index += 1) {
- await Promise.resolve();
- await new Promise((resolve) => window.setTimeout(resolve, 0));
- }
- flushSync(() => {});
-}
-
-describe("TaskChatRedesignGate", () => {
- let container: HTMLDivElement;
- let root: Root | null = null;
-
- async function renderGate() {
- root = createRoot(container);
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- flushSync(() => {
- root!.render(
-
-
- ,
- );
- });
- await flushReact();
- }
-
- beforeEach(() => {
- container = document.createElement("div");
- document.body.appendChild(container);
- });
-
- afterEach(() => {
- flushSync(() => {
- root?.unmount();
- });
- root = null;
- container.remove();
- vi.clearAllMocks();
- });
-
- it("redirects to the company home when the flag is off (flag-off isolation)", async () => {
- mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: false });
- await renderGate();
- const navigate = container.querySelector('[data-testid="navigate"]');
- expect(navigate?.getAttribute("data-to")).toBe("/dashboard");
- expect(container.querySelector('[data-testid="outlet"]')).toBeNull();
- });
-
- it("renders the gated harness when the flag is on", async () => {
- mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: true });
- await renderGate();
- expect(container.querySelector('[data-testid="outlet"]')).not.toBeNull();
- expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
- });
-});
-
-describe("useTaskChatRedesignEnabled", () => {
- it("resolves to flag-off when rendered without a QueryClientProvider", () => {
- // Rendered detached (no provider) it must default OFF and loaded — this is
- // what makes flag-off the provable current behavior at every call site.
- let captured: { enabled: boolean; loaded: boolean } | null = null;
- function Probe() {
- captured = useTaskChatRedesignEnabled();
- return null;
- }
- const container = document.createElement("div");
- const root = createRoot(container);
- flushSync(() => root.render( ));
- flushSync(() => root.unmount());
- expect(captured).toEqual({ enabled: false, loaded: true });
- });
-});
diff --git a/ui/src/components/TaskChatRedesignGate.tsx b/ui/src/components/TaskChatRedesignGate.tsx
deleted file mode 100644
index 59f2bb8c97b..00000000000
--- a/ui/src/components/TaskChatRedesignGate.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { Navigate, Outlet } from "@/lib/router";
-import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled";
-
-/**
- * Layout route guard for Task Chat Redesign dev surfaces (e.g. the
- * /dev/task-chat-lab harness).
- *
- * The gated routes stay registered (gating is presentation-only, no 404
- * flash); when the experimental flag is off the element redirects to the
- * company home instead of rendering. While the flag is still loading nothing
- * renders so an enabled user is not bounced away by a premature redirect.
- */
-export function TaskChatRedesignGate() {
- const { enabled, loaded } = useTaskChatRedesignEnabled();
- if (!loaded) return null;
- if (!enabled) return ;
- return ;
-}
diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx
index 86581ff2a6b..017287071dd 100644
--- a/ui/src/components/TaskChatThread.test.tsx
+++ b/ui/src/components/TaskChatThread.test.tsx
@@ -5,10 +5,13 @@ import { forwardRef, useImperativeHandle, type ForwardedRef } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatThread } from "./TaskChatThread";
+const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
+
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
- useLiveRunTranscripts: () => ({ transcriptByRun: new Map() }),
+ useLiveRunTranscripts: () => transcriptState,
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
@@ -31,6 +34,7 @@ let root: Root | null = null;
beforeEach(() => {
localStorage.clear();
+ transcriptState.transcriptByRun.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@@ -44,7 +48,7 @@ afterEach(() => {
});
function render(ui: ReactElement) {
- flushSync(() => root!.render(ui));
+ flushSync(() => root!.render({ui} ));
}
describe("TaskChatThread draft pass-through", () => {
@@ -63,3 +67,110 @@ describe("TaskChatThread draft pass-through", () => {
.toBe("half-written thought");
});
});
+
+describe("TaskChatThread live transcript", () => {
+ it("renders in-flight output through TaskChatLiveTail, dropping the debug plumbing (PAP-463 C1)", () => {
+ // Interleave the exact noise the old RunTranscriptView tail surfaced (init
+ // row, stdout/stderr/system dumps) with real content. Only the streamed
+ // reply markdown and the tool row may reach the thread.
+ transcriptState.transcriptByRun.set("run-1", [
+ { kind: "init", ts: "2026-08-07T00:00:00.000Z", model: "claude", sessionId: "sess-INITMARKER" },
+ { kind: "system", ts: "2026-08-07T00:00:00.000Z", text: "SYSTEMNOISE environment hint" },
+ { kind: "stdout", ts: "2026-08-07T00:00:00.000Z", text: "STDOUTNOISE raw json dump" },
+ { kind: "stderr", ts: "2026-08-07T00:00:00.000Z", text: "STDERRNOISE adapter timeout note" },
+ {
+ kind: "assistant",
+ ts: "2026-08-07T00:00:00.000Z",
+ text: "Streaming through the shared renderer",
+ },
+ { kind: "tool_call", ts: "2026-08-07T00:00:00.000Z", name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
+ ]);
+
+ render(
+ {}}
+ issueStatus="in_progress"
+ activeRun={{
+ id: "run-1",
+ status: "running",
+ invocationSource: "issue",
+ triggerDetail: null,
+ startedAt: "2026-08-07T00:00:00.000Z",
+ finishedAt: null,
+ createdAt: "2026-08-07T00:00:00.000Z",
+ agentId: "agent-1",
+ agentName: "Coder",
+ adapterType: "codex_local",
+ }}
+ />,
+ );
+
+ const tail = container.querySelector('[data-testid="task-chat-live-transcript"]');
+ expect(tail).not.toBeNull();
+ // Clean content survives: streamed reply markdown + tool row.
+ expect(tail!.textContent).toContain("Streaming through the shared renderer");
+ expect(tail!.textContent).toContain("src/app.ts");
+ // None of the debug plumbing reaches the thread.
+ for (const noise of ["INITMARKER", "SYSTEMNOISE", "STDOUTNOISE", "STDERRNOISE"]) {
+ expect(container.textContent).not.toContain(noise);
+ }
+ });
+
+ it("keeps the transcript mounted through run settle until the settled turn renders (PAP-462 B4)", () => {
+ transcriptState.transcriptByRun.set("run-1", [
+ {
+ kind: "assistant",
+ ts: "2026-08-07T00:00:00.000Z",
+ text: "Last words before the run stops",
+ },
+ ]);
+
+ const liveProps = {
+ comments: [] as never[],
+ onAdd: async () => {},
+ issueStatus: "in_progress",
+ activeRun: {
+ id: "run-1",
+ status: "running",
+ invocationSource: "issue" as const,
+ triggerDetail: null,
+ startedAt: "2026-08-07T00:00:00.000Z",
+ finishedAt: null,
+ createdAt: "2026-08-07T00:00:00.000Z",
+ agentId: "agent-1",
+ agentName: "Coder",
+ adapterType: "codex_local",
+ },
+ };
+
+ render( );
+ expect(
+ container.querySelector('[data-testid="task-chat-live-transcript"]'),
+ ).not.toBeNull();
+
+ // The run settles: the issue goes terminal and the run reports succeeded, so
+ // `liveRun` flips to null — but no reply comment has landed yet. The
+ // transcript must NOT vanish; it stays mounted (now as a settled tail) until
+ // its settled turn/comment renders.
+ render(
+ ,
+ );
+
+ expect(
+ container.querySelector('[data-testid="task-chat-live-transcript"]'),
+ ).not.toBeNull();
+ expect(container.textContent).toContain("Last words before the run stops");
+ // The pill has settled to its "Worked" state rather than flipping back to a
+ // spinner while it waits for the reply comment.
+ expect(container.textContent).toContain("Worked");
+ });
+});
diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx
index 769a8754d92..740cc4d08c3 100644
--- a/ui/src/components/TaskChatThread.tsx
+++ b/ui/src/components/TaskChatThread.tsx
@@ -1,14 +1,16 @@
-import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { IssueChatThread } from "@/components/IssueChatThread";
-import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts";
+import {
+ useLiveRunTranscripts,
+ type RunTranscriptSource,
+} from "@/components/transcript/useLiveRunTranscripts";
+import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
import {
assembleThreadItems,
attachSettledTurns,
buildTurnSummary,
coalesceSettledTurns,
- deriveRunStatusLabel,
- isNestableLiveChild,
isTerminalRunStatus,
prependIssueBrief,
settledRunChildren,
@@ -16,6 +18,10 @@ import {
type SettledTurnMergeMeta,
} from "@/components/task-chat/transcript-adapter";
import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble";
+import {
+ TaskChatLiveRunPill,
+ toolCountSummaryFromEntries,
+} from "@/components/task-chat/TaskChatLiveRunPill";
import type {
TaskChatInteractionItem,
TaskChatItem,
@@ -23,6 +29,11 @@ import type {
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
+import {
+ interactionThreadAnchorMs,
+ isSuppressedThreadInteraction,
+} from "@/components/task-chat/interaction-thread-order";
+import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
import type { FeedbackVoteValue } from "@paperclipai/shared";
import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView";
@@ -42,32 +53,30 @@ function toMs(value: Date | string | null | undefined): number {
return Number.isNaN(ms) ? 0 : ms;
}
+// PAP-462 B4: backstop for how long the just-settled run's transcript stays
+// mounted when neither a settled turn nor a reply comment ever arrives to hand
+// off to (e.g. a stopped run with no tool activity). Normal completions hand off
+// well within this as soon as the settled turn/comment lands.
+const SETTLING_TAIL_MAX_MS = 15_000;
+
export type TaskChatThreadProps = ComponentProps;
/**
- * Task Chat Redesign thread (experimental flag: `enableTaskChatRedesign`).
+ * Chat-style task thread — the default task detail experience.
*
- * Renders the redesigned, Claude-Code-style thread for the live task. It shares
+ * Renders the Claude-Code-style thread for the live task. It shares
* IssueChatThread's exact prop type — so the IssueDetail seam ternary
- * (`redesign ? TaskChatThread : IssueChatThread`) type-checks with no casts.
+ * (`classic ? IssueChatThread : TaskChatThread`, flag:
+ * `enableClassicTaskInterface`) type-checks with no casts.
*
* Two data sources feed the render layer, both reused from the existing thread:
* - the comment stream (incl. optimistic echoes) → author-typed bubbles, and
* - the live run transcript (useLiveRunTranscripts, the same poll+websocket
- * source the current thread uses) → the in-flight turn streams
- * thinking → tool → diff → responding, capped by a live "running" status
- * pill.
+ * source the current thread uses) → clean TaskChatLiveTail rows (tool cards,
+ * diffs, streamed reply markdown) while in flight, via the same
+ * transcriptToTaskChatItems converter the settled turns use (PAP-463).
*
- * Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
- * ONE expandable parent row — its status line (whimsy gerund or in-flight
- * tool state + elapsed + tokens) is the turn's single visible line, with the
- * chronological tool activity nested behind an expand (PAP-354). Mid-run
- * agent text (interstitial updates between tool calls) is ephemeral
- * (PAP-361, round 9): while one streams it occupies a dedicated one-line row
- * PERMANENTLY RESERVED above the status line (so the layout above never
- * jumps), and when it finishes the text slides out and the slot sits empty —
- * nothing persists; the run log and the classic transcript remain the
- * archive. When the run terminates, its settled turn anchors after the run's
+ * Once a run terminates, its settled turn anchors after the run's
* last comment (comment.runId linkage) and — when it directly follows that
* reply bubble — attaches to it: the "✓ Worked · …" summary renders appended
* to the bubble's always-visible timestamp line (round 9), still expandable
@@ -229,6 +238,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
});
for (const interaction of interactions ?? []) {
+ // Withdrawn/superseded confirmations are retracted calls to action — drop
+ // them so a dead card never stacks above the one that replaced it
+ // (PAP-416).
+ if (isSuppressedThreadInteraction(interaction)) continue;
+ // A never-rendered card — a degenerate `ask_user_questions` (e.g. the
+ // onboarding `Test / A` placeholder) or a stale sibling superseded by a
+ // newer question (PAP-437) — is filtered here so it leaves no empty slot
+ // or gap in the ordered backbone (PAP-424, plan from PAP-420).
+ if (shouldHideInteractionCard(interaction)) continue;
const createdAtMs = toMs(interaction.createdAt);
const handoffAtMs =
interaction.kind === "request_confirmation" && interaction.sourceRunId
@@ -243,7 +261,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: null;
const id = `interaction:${interaction.id}`;
entries.push({
- ms: handoffAtMs ?? createdAtMs,
+ // A resolved card settles at its resolution time so it never reads
+ // above a message written before the user answered (PAP-416); pending
+ // cards keep the request-time (handoff/createdAt) slot.
+ ms: interactionThreadAnchorMs(interaction, handoffAtMs ?? createdAtMs),
order: 2,
id,
item: { id, kind: "interaction", interaction },
@@ -274,7 +295,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// heavy assembly memo doesn't recompute on every parent render.
const hasBrief = Boolean(issueBrief);
- const items = useMemo(() => {
+ const { items, settledRunIds } = useMemo<{ items: TaskChatItem[]; settledRunIds: Set }>(() => {
+ // Runs whose settled turn made it into the assembled thread — the live tail
+ // hands off to this as its "the settled turn has rendered" signal (PAP-462
+ // B4), so the transcript stays mounted through the settle gap without ever
+ // double-rendering beside its own settled turn.
+ const settledRunIds = new Set();
// Settled turns for every terminal run whose transcript we have. Messages
// and thinking are excluded entirely (PAP-361): the final reply already
// landed as the run's comment bubble, interstitial updates are ephemeral
@@ -303,6 +329,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
});
const children = settledRunChildren(parsed);
if (children.length === 0) continue;
+ settledRunIds.add(source.id);
const failed = source.status !== "succeeded";
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
turnMergeMetaById.set(`${source.id}:turn`, {
@@ -347,57 +374,133 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// (PAP-367).
const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored);
- if (liveRun) {
- const entries = transcriptByRun.get(liveRun.id) ?? [];
- const parsed = transcriptToTaskChatItems(entries, {
- runId: liveRun.id,
- agentName: liveRun.agentName,
- running: true,
- });
- // Parent-row model (PAP-354): the run's status line IS the live turn —
- // one expandable row owning the activity. Only tool/usage rows nest
- // inside it; a streaming interstitial update renders in the reserved row
- // above the status line (liveStatus.selfTalk, PAP-361/round 9) and
- // vanishes when it completes. The parent row sits last; on settle its
- // summary attaches to the reply bubble's timestamp line.
- const children = parsed.filter(isNestableLiveChild);
- const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
- const queued = liveRun.status === "queued";
- const status = queued
- ? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined }
- : deriveRunStatusLabel(entries);
- out.push({
- id: `${liveRun.id}:turn`,
- kind: "turn",
- settled: false,
- items: children,
- summary: buildTurnSummary(entries),
- liveStatus: {
- id: `${liveRun.id}:status`,
- kind: "status",
- status: "running",
- label: status.label,
- detail: status.detail,
- toolName: status.toolName,
- selfTalk: status.selfTalk,
- startedAtMs: startedAt ?? undefined,
- },
- });
- }
// PAP-362: two runs replying back-to-back (same agent, nothing but the
// agent's own bubbles between) fold into ONE "Worked" row below the last
- // reply; a user message, interaction, or the live turn keeps them apart.
+ // reply; a user message or interaction keeps them apart.
// Round 9: a settled turn directly following its own agent's reply bubble
// then attaches to that bubble — the "Worked · …" summary renders on the
// bubble's always-visible timestamp line instead of as a standalone row.
// PAP-375: the description-as-first-bubble placeholder prepends LAST, after
// every assembly/merge pass, so nothing can ever sort above it.
- return prependIssueBrief(
- attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
- hasBrief,
- );
+ return {
+ items: prependIssueBrief(
+ attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
+ hasBrief,
+ ),
+ settledRunIds,
+ };
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
+ // PAP-462 B4: the moment a run settles, `liveRun` flips to null — but its
+ // settled turn (or reply comment) can take seconds to arrive on the next
+ // comment refetch. Without a handoff the whole transcript unmounts that frame,
+ // blinking out already-streamed output before its settled form renders. Snapshot
+ // the just-settled run so its now-static transcript stays mounted through the gap.
+ const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null);
+ const prevLiveRunRef = useRef(null);
+ // Layout effect (not passive): snapshot the settled run before the browser
+ // paints the `liveRun === null` frame, so the tail never blinks empty for a
+ // frame between the run stopping and this snapshot committing.
+ useLayoutEffect(() => {
+ if (liveRun) {
+ prevLiveRunRef.current = liveRun;
+ // A fresh live run supersedes any lingering settled remnant.
+ setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current));
+ return;
+ }
+ const prev = prevLiveRunRef.current;
+ prevLiveRunRef.current = null;
+ if (!prev) return;
+ setSettlingRun({
+ id: prev.id,
+ startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt),
+ });
+ }, [liveRun]);
+
+ // Hand off once the settled turn or its reply comment is in the thread; a
+ // stopped run that yields neither is released by the backstop timeout so the
+ // tail never lingers indefinitely.
+ const settledRunRendered =
+ settlingRun != null &&
+ (settledRunIds.has(settlingRun.id) ||
+ comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt));
+ useEffect(() => {
+ if (!settlingRun) return;
+ if (settledRunRendered) {
+ setSettlingRun(null);
+ return;
+ }
+ const timer = window.setTimeout(() => setSettlingRun(null), SETTLING_TAIL_MAX_MS);
+ return () => window.clearTimeout(timer);
+ }, [settlingRun, settledRunRendered]);
+
+ // The tail streams the live run, or — through the settle gap — the last run's
+ // now-static transcript until its settled form renders (PAP-462 B4).
+ const showSettlingTail =
+ !liveRun &&
+ settlingRun != null &&
+ !settledRunRendered &&
+ (transcriptByRun.get(settlingRun.id)?.length ?? 0) > 0;
+ const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
+ const tailStreaming = Boolean(liveRun);
+ const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
+ const tailContentKey = tailEntries.reduce((total, entry) => {
+ if ("text" in entry) return total + entry.text.length;
+ if ("content" in entry) return total + entry.content.length;
+ return total + entry.kind.length;
+ }, tailEntries.length);
+ const threadContentKey = taskChatContentKey(items) + tailContentKey;
+
+ // Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish
+ // (once terminal), and the "called N tools" summary. Memoized on the
+ // transcript content key so the O(n) tool count is not re-walked every render
+ // while the pill's own second-tick keeps the elapsed readout moving. Through
+ // the settle gap the run reads terminal, so the pill lands on its "Worked"
+ // state instead of flipping back to a spinner.
+ const settlingFinishedAt = settlingRun ? linkedRunMetaById.get(settlingRun.id)?.finishedAt : undefined;
+ const tailStatus = liveRun
+ ? liveRun.status
+ : runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded";
+ const tailStartedAtMs = liveRun
+ ? (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)
+ : settlingRun?.startedAtMs ?? null;
+ const tailFinishedAtMs = liveRun
+ ? liveRun.finishedAt
+ ? toMs(liveRun.finishedAt)
+ : null
+ : settlingFinishedAt
+ ? toMs(settlingFinishedAt)
+ : null;
+ const tailToolSummary = useMemo(
+ () => (tailRunId ? toolCountSummaryFromEntries(tailEntries) : null),
+ // tailEntries is a fresh array each render; tailContentKey tracks its content.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [tailRunId, tailContentKey],
+ );
+
+ // The tail's clean rows (PAP-463 C1): the streaming transcript parsed through
+ // the SAME transcriptToTaskChatItems converter the settled turns use, which
+ // drops the debug plumbing (init/stdout/stderr/system) so RunTranscriptView's
+ // noise can never reach the thread. Memoized on the content key (tailEntries
+ // is a fresh array each render) so the O(n) parse is not re-walked while the
+ // pill's own second-tick keeps the elapsed readout moving. `running` follows
+ // tailStreaming: true while live, false through the settle gap — so the tail
+ // renders identically either side of the run finishing.
+ const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName;
+ const tailItems = useMemo(
+ () =>
+ tailRunId
+ ? transcriptToTaskChatItems(tailEntries, {
+ runId: tailRunId,
+ agentName: tailAgentName,
+ running: tailStreaming,
+ })
+ : [],
+ // tailEntries is a fresh array each render; tailContentKey tracks its content.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [tailRunId, tailContentKey, tailStreaming, tailAgentName],
+ );
+
// Feedback votes keyed by the comment they target (targetType
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
// 👍/👎 state to each agent bubble by its comment id (PAP-413).
@@ -471,7 +574,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// in document flow instead (the same scroll={false} path the previews use)
// and track auto-follow against window scroll. Desktop stays byte-identical.
const { isMobile } = useSidebar();
- useWindowAutoFollow(isMobile ? taskChatContentKey(items) : 0, isMobile);
+ useWindowAutoFollow(isMobile ? threadContentKey : 0, isMobile);
return (
- {items.length === 0 ? (
+ {items.length === 0 && !tailRunId ? (
{threadHeader ? (
: undefined}
renderMessageActions={renderMessageActions}
+ tail={tailRunId ? (
+
+
+
+
+ ) : null}
+ contentKey={threadContentKey}
scroll={!isMobile}
/>
)}
diff --git a/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.test.tsx b/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.test.tsx
deleted file mode 100644
index 92933483162..00000000000
--- a/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.test.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-// @vitest-environment jsdom
-
-import { act } from "react";
-import { createRoot } from "react-dom/client";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { MemoryRouter } from "react-router-dom";
-import type { Issue, RequestConfirmationInteraction } from "@paperclipai/shared";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { queryKeys } from "@/lib/queryKeys";
-import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab";
-import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
-
-const mockIssuesApi = vi.hoisted(() => ({
- listInteractions: vi.fn(),
- listAcceptedPlanDecompositions: vi.fn(),
- acceptInteraction: vi.fn(),
- rejectInteraction: vi.fn(),
-}));
-
-vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
-vi.mock("@/hooks/useIssuePlanDocument", () => ({
- useIssuePlanDocument: () => ({ data: undefined, isLoading: false }),
-}));
-vi.mock("../PropertiesPanel", () => ({
- PROPERTIES_PANE_FOOTER_SLOT_ID: "properties-pane-footer-slot",
-}));
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
-
-const issue = {
- id: "issue-1",
- identifier: "PAP-1",
-} as Issue;
-
-const confirmation = {
- id: "confirmation-1",
- companyId: "company-1",
- issueId: issue.id,
- kind: "request_confirmation",
- status: "pending",
- continuationPolicy: "wake_assignee",
- resolverPolicy: "board_only",
- requestedResolverPolicy: "board_only",
- effectiveResolverPolicy: "board_only",
- createdAt: "2026-08-05T00:00:00.000Z",
- updatedAt: "2026-08-05T00:00:00.000Z",
- payload: {
- version: 1,
- prompt: "Approve this plan?",
- acceptLabel: "Approve plan",
- rejectLabel: "Request changes",
- rejectRequiresReason: true,
- allowDeclineReason: true,
- target: { type: "issue_document", key: "plan", revisionId: "rev-1" },
- },
-} satisfies RequestConfirmationInteraction;
-
-let root: ReturnType
| null = null;
-let container: HTMLDivElement | null = null;
-let client: QueryClient | null = null;
-
-function render(element: React.ReactElement) {
- container = document.createElement("div");
- document.body.appendChild(container);
- root = createRoot(container);
- client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
- client.setQueryData(queryKeys.issues.interactions(issue.id), [confirmation]);
- client.setQueryData(queryKeys.issues.acceptedPlanDecompositions(issue.id), []);
- act(() => root?.render(
-
- {element}
- ,
- ));
- return container;
-}
-
-afterEach(() => {
- if (root) act(() => root?.unmount());
- root = null;
- container?.remove();
- container = null;
- client?.clear();
- client = null;
- vi.unstubAllGlobals();
- vi.clearAllMocks();
-});
-
-describe("IssuePlanConfirmationActionBar", () => {
- it("renders a pending plan confirmation even before its plan document exists", () => {
- const rendered = render( );
-
- expect(rendered.querySelector('[data-testid="plan-pane-action-bar"]')).not.toBeNull();
- expect(rendered.textContent).toContain("Approve plan");
- expect(rendered.textContent).toContain("Request changes");
- });
-
- it("moves into a footer slot that mounts on the next paint", () => {
- let resolveNextPaint: FrameRequestCallback | undefined;
- vi.stubGlobal("requestAnimationFrame", vi.fn((callback: FrameRequestCallback) => {
- resolveNextPaint = callback;
- return 1;
- }));
- vi.stubGlobal("cancelAnimationFrame", vi.fn());
-
- const rendered = render( );
- const footer = document.createElement("div");
- footer.id = "properties-pane-footer-slot";
- document.body.appendChild(footer);
-
- act(() => resolveNextPaint?.(0));
-
- expect(rendered.querySelector('[data-testid="plan-pane-action-bar"]')).toBeNull();
- expect(footer.querySelector('[data-testid="plan-pane-action-bar"]')).not.toBeNull();
- footer.remove();
- });
-});
diff --git a/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx b/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx
deleted file mode 100644
index e3da0841063..00000000000
--- a/ui/src/components/issue-properties/IssuePlanConfirmationActionBar.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import { useEffect, useState } from "react";
-import { createPortal } from "react-dom";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Loader2 } from "lucide-react";
-import type { Issue, IssueThreadInteraction, RequestConfirmationInteraction } from "@paperclipai/shared";
-import { issuesApi } from "@/api/issues";
-import { queryKeys } from "@/lib/queryKeys";
-import { cn } from "@/lib/utils";
-import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
-import { PROPERTIES_PANE_FOOTER_SLOT_ID } from "../PropertiesPanel";
-
-/** The pending confirmation that targets the issue's `plan` document, if any. */
-function findPendingPlanConfirmation(
- interactions: IssueThreadInteraction[] | undefined,
-): RequestConfirmationInteraction | null {
- for (const interaction of interactions ?? []) {
- if (interaction.kind !== "request_confirmation") continue;
- if (interaction.status !== "pending") continue;
- const target = interaction.payload.target;
- if (target?.type === "issue_document" && target.key === "plan") return interaction;
- }
- return null;
-}
-
-interface IssuePlanConfirmationActionBarProps {
- issue: Issue;
- /** Inline hosts (mobile sheet) render the bar in place instead of portaling
- * it into the pane's pinned footer slot. */
- inline?: boolean;
-}
-
-/**
- * Sticky action bar for the Plan pane (flag: enableTaskChatRedesign): while a
- * plan confirmation is pending, its CTAs stay pinned below the pane's scroll
- * area so the board can approve or send back the plan without hunting for the
- * card in the thread. Mirrors the thread card's semantics (accept/reject labels
- * and the optional decline reason) against the same interactions API.
- */
-export function IssuePlanConfirmationActionBar({ issue, inline }: IssuePlanConfirmationActionBarProps) {
- const queryClient = useQueryClient();
- const { data: interactions } = useQuery({
- queryKey: queryKeys.issues.interactions(issue.id),
- queryFn: () => issuesApi.listInteractions(issue.id),
- });
- const confirmation = findPendingPlanConfirmation(interactions);
-
- const [footerSlot, setFooterSlot] = useState(null);
- useEffect(() => {
- if (inline) {
- setFooterSlot(null);
- return;
- }
-
- const resolveFooterSlot = () => {
- setFooterSlot(document.getElementById(PROPERTIES_PANE_FOOTER_SLOT_ID));
- };
-
- // The properties pane and this action bar can mount in either order. Check
- // once synchronously, then again on the next paint so the footer slot is
- // found when it is mounted later in the same commit.
- resolveFooterSlot();
- const frame = requestAnimationFrame(resolveFooterSlot);
- return () => cancelAnimationFrame(frame);
- }, [confirmation?.id, inline]);
-
- const [rejecting, setRejecting] = useState(false);
- const [rejectReason, setRejectReason] = useState("");
- const [rejectAttempted, setRejectAttempted] = useState(false);
- const [actionError, setActionError] = useState(null);
-
- const invalidate = () => {
- void queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issue.id) });
- };
- const accept = useMutation({
- mutationFn: (interactionId: string) => issuesApi.acceptInteraction(issue.id, interactionId),
- onSuccess: invalidate,
- onError: () => setActionError("Couldn't confirm — try again."),
- });
- const reject = useMutation({
- mutationFn: ({ interactionId, reason }: { interactionId: string; reason?: string }) =>
- issuesApi.rejectInteraction(issue.id, interactionId, reason),
- onSuccess: () => {
- setRejecting(false);
- setRejectReason("");
- invalidate();
- },
- onError: () => setActionError("Couldn't send that back — try again."),
- });
-
- // Interaction changed under us (resolved elsewhere, superseded): reset.
- useEffect(() => {
- setRejecting(false);
- setRejectReason("");
- setRejectAttempted(false);
- setActionError(null);
- }, [confirmation?.id, confirmation?.status]);
-
- if (!confirmation) return null;
-
- const working = accept.isPending ? "accept" : reject.isPending ? "reject" : null;
- const rejectRequiresReason = confirmation.payload.rejectRequiresReason === true;
- const allowDeclineReason = confirmation.payload.allowDeclineReason !== false;
- const trimmedReason = rejectReason.trim();
- const reasonInvalid = rejectRequiresReason && trimmedReason.length === 0;
-
- const handleReject = () => {
- setRejectAttempted(true);
- if (reasonInvalid) return;
- setActionError(null);
- reject.mutate({ interactionId: confirmation.id, reason: trimmedReason || undefined });
- };
-
- const bar = (
-
- {rejecting ? (
-
-
setRejectReason(event.target.value)}
- placeholder={
- confirmation.payload.declineReasonPlaceholder
- ?? (confirmation.payload.acceptLabel === "Approve plan"
- ? "Optional: what would you like revised?"
- : "Optional: tell the agent what you'd change.")
- }
- aria-invalid={rejectAttempted && reasonInvalid}
- className={cn(
- "min-h-20 bg-background text-sm",
- rejectAttempted && reasonInvalid && "border-rose-500 focus-visible:ring-rose-500/25",
- )}
- />
- {rejectAttempted && reasonInvalid ? (
- A decline reason is required.
- ) : null}
-
- ) : null}
- {actionError ? (
-
{actionError}
- ) : null}
-
- {rejecting ? (
- <>
- {
- setRejecting(false);
- setRejectAttempted(false);
- }}
- >
- Cancel
-
-
- {working === "reject" ? (
- <>
-
- Sending back...
- >
- ) : (
- confirmation.payload.rejectLabel ?? "Decline"
- )}
-
- >
- ) : (
- {
- if (!allowDeclineReason) {
- handleReject();
- return;
- }
- setRejectAttempted(false);
- setRejecting(true);
- }}
- >
- {working === "reject" ? (
- <>
-
- Sending back...
- >
- ) : (
- confirmation.payload.rejectLabel ?? "Decline"
- )}
-
- )}
- {
- setActionError(null);
- accept.mutate(confirmation.id);
- }}
- >
- {working === "accept" ? (
- <>
-
- Confirming...
- >
- ) : (
- confirmation.payload.acceptLabel ?? "Confirm"
- )}
-
-
-
- );
-
- return footerSlot ? createPortal(bar, footerSlot) : bar;
-}
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 48112bd8f39..edc325024c5 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -171,38 +171,40 @@ export function IssueProperties({
queryFn: () => instanceSettingsApi.getExperimental(),
});
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
- // Task Chat Redesign: gate the Properties | Plans | Artifacts tab shell. Flag
- // OFF renders today's stacked sections verbatim (no Tabs wrapper). This pane
- // is always task-scoped, so the flag alone is a sufficient gate.
- const taskChatRedesignEnabled = experimentalSettings?.enableTaskChatRedesign === true;
- // When hosted by the redesigned PropertiesPanel, the tab strip portals into
+ // Classic Task Interface: gate the Properties | Plans | Artifacts tab shell.
+ // Flag ON renders the legacy stacked sections verbatim (no Tabs wrapper);
+ // flag OFF — including while settings load — renders the chat-style tab
+ // shell. This pane is always task-scoped, so the flag alone is a sufficient
+ // gate.
+ const taskChatShellEnabled = experimentalSettings?.enableClassicTaskInterface !== true;
+ // When hosted by the resizable PropertiesPanel, the tab strip portals into
// the pane's header bar (left of the window controls). The slot only exists
// once the panel has committed, hence the effect; inline hosts (mobile sheet)
// keep the tab strip in place.
const [paneHeaderSlot, setPaneHeaderSlot] = useState(null);
useEffect(() => {
- if (!taskChatRedesignEnabled || inline) {
+ if (!taskChatShellEnabled || inline) {
setPaneHeaderSlot(null);
return;
}
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
- }, [taskChatRedesignEnabled, inline]);
+ }, [taskChatShellEnabled, inline]);
// Plan earns a tab as soon as an issue is in planning mode, even before the
// plan document arrives. This keeps an expected plan surface visible and
// lets its diagnostic empty state explain what is missing.
// Same query keys as the tab bodies, so these share their cached fetches.
const { data: paneTabPlanDocument } = useIssuePlanDocument(
- taskChatRedesignEnabled ? issue.id : null,
+ taskChatShellEnabled ? issue.id : null,
);
const { data: paneTabAcceptedPlans } = useQuery({
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
- enabled: taskChatRedesignEnabled,
+ enabled: taskChatShellEnabled,
});
const { data: paneTabAttachments } = useQuery({
queryKey: queryKeys.issues.attachments(issue.id),
queryFn: () => issuesApi.listAttachments(issue.id),
- enabled: taskChatRedesignEnabled,
+ enabled: taskChatShellEnabled,
});
const hasPlanTab =
Boolean(paneTabPlanDocument)
@@ -210,6 +212,20 @@ export function IssueProperties({
|| issue.workMode === "planning";
const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0;
const [paneTab, setPaneTab] = useState("properties");
+ // Once a plan document exists, surface it: switch the pane to the Plan tab so
+ // the write-up is exposed alongside the plan-approval card, instead of leaving
+ // the user on Properties. Only auto-switch until the user picks a tab by hand —
+ // after that their choice wins. Ref-guarded so it fires once per mount.
+ const paneTabUserChosenRef = useRef(false);
+ const handlePaneTabChange = useCallback((value: string) => {
+ paneTabUserChosenRef.current = true;
+ setPaneTab(value);
+ }, []);
+ useEffect(() => {
+ if (hasPlanTab && !paneTabUserChosenRef.current) {
+ setPaneTab("plans");
+ }
+ }, [hasPlanTab]);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
/** When a run is live, a selection is staged here until the operator confirms
@@ -2496,10 +2512,10 @@ export function IssueProperties({
);
- // Flag OFF (or non-redesign hosts): today's stacked pane, byte-for-byte.
- if (!taskChatRedesignEnabled) return propertiesBody;
+ // Classic Task Interface ON: the legacy stacked pane, byte-for-byte.
+ if (!taskChatShellEnabled) return propertiesBody;
- // Flag ON with nothing to switch between: no tab strip — the header bar
+ // Chat-style with nothing to switch between: no tab strip — the header bar
// shows a plain title and the pane body is just the properties stack.
if (!hasPlanTab && !hasArtifactsTab) {
return (
@@ -2552,7 +2568,7 @@ export function IssueProperties({
);
return (
-
+
{paneHeaderSlot
? createPortal(
// Portals keep React context but break the DOM tree the Tailwind
diff --git a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
index e5114967f25..47a7f47aefd 100644
--- a/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesArtifactsTab.tsx
@@ -15,7 +15,7 @@ function formatBytes(n: number): string {
}
/**
- * Artifacts tab of the redesigned properties pane (flag: enableTaskChatRedesign).
+ * Artifacts tab of the properties pane.
*
* A read-only gallery of the task's attachments / work products. Uploads,
* previews, and deletes stay on the existing attachment surfaces for the
diff --git a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx
index db42607c6a8..a01b9e129ff 100644
--- a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx
+++ b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx
@@ -8,12 +8,11 @@ import { MarkdownBody } from "@/components/MarkdownBody";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { useLocation } from "@/lib/router";
-import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
interface IssuePropertiesPlansTabProps {
issue: Issue;
- /** True when hosted outside the properties panel (mobile sheet) — the plan
- * confirmation bar then renders in place instead of the pane footer slot. */
+ /** Retained for host parity with the other tabs; the Plans tab no longer
+ * renders its own approval control (PAP-418). */
inline?: boolean;
}
@@ -28,15 +27,15 @@ function hasPendingPlanConfirmation(interactions: IssueThreadInteraction[] | und
}
/**
- * Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign).
+ * Plans tab of the properties pane.
*
- * Owns the plan surface with the flag ON: the `plan` document itself (formerly
- * pinned above the tabs via IssueDocumentsSection, which the chat shell gates
- * off) rendered above the accepted-plan decomposition history. Structured live
+ * Owns the plan surface: the `plan` document itself (formerly pinned above
+ * the tabs via IssueDocumentsSection, which the chat shell gates off)
+ * rendered above the accepted-plan decomposition history. Structured live
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in
* the /dev/task-chat-lab harness).
*/
-export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
+export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
const location = useLocation();
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
@@ -53,35 +52,31 @@ export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansT
if (!planDocument && !hasPlans) {
return (
- <>
- {/* This is deliberately outside the plan-document gate: an interaction
- can arrive before its plan document query resolves or persists. */}
-
-
- {planDocumentLoading ? (
- "Loading plan…"
- ) : issue.workMode === "planning" ? (
-
-
This task is in plan mode but no plan document has been written yet.
- {pendingPlanConfirmation ? (
-
- A plan confirmation is pending, but the plan document it should confirm is missing.
-
- ) : null}
-
- ) : (
- "No plan yet. The plan document, accepted plans, and their revisions will appear here."
- )}
-
- >
+
+ {planDocumentLoading ? (
+ "Loading plan…"
+ ) : issue.workMode === "planning" ? (
+
+
This task is in plan mode but no plan document has been written yet.
+ {pendingPlanConfirmation ? (
+
+ A plan confirmation is pending, but the plan document it should confirm is missing.
+
+ ) : null}
+
+ ) : (
+ "No plan yet. The plan document, accepted plans, and their revisions will appear here."
+ )}
+
);
}
return (
- {/* Pending plan confirmation: its CTAs pin to the pane's footer slot so
- they stay visible while the plan scrolls. */}
-
+ {/* Plan approval lives in ONE place — the plan confirmation card in the
+ conversation thread (PAP-418). This tab now only shows the plan itself
+ and its accepted-revision history, so there is no second surface to
+ approve from. */}
{planDocument ? (
diff --git a/ui/src/components/task-chat/TaskChatBubbleActions.tsx b/ui/src/components/task-chat/TaskChatBubbleActions.tsx
index 33f6c9f179a..f749acf7c10 100644
--- a/ui/src/components/task-chat/TaskChatBubbleActions.tsx
+++ b/ui/src/components/task-chat/TaskChatBubbleActions.tsx
@@ -1,11 +1,11 @@
+import { useState } from "react";
import type {
FeedbackDataSharingPreference,
FeedbackVoteValue,
} from "@paperclipai/shared";
-import {
- BubbleCopyButton,
- IssueChatFeedbackButtons,
-} from "@/components/AgentBubbleActionRow";
+import { copyTextToClipboard } from "@/lib/clipboard";
+import { IssueChatFeedbackButtons } from "@/components/AgentBubbleActionRow";
+import { Check, Copy } from "lucide-react";
/** Feedback-vote wiring for an agent bubble, resolved per comment by the host. */
export interface TaskChatBubbleFeedback {
@@ -22,10 +22,9 @@ export interface TaskChatBubbleFeedback {
* Compact copy · 👍 · 👎 cluster prepended to an agent bubble's footer line
* (PAP-413), leading the "✓ Worked · …" turn summary (or the bare timestamp
* when the reply had no run activity). It reuses the shared
- * {@link BubbleCopyButton} and {@link IssueChatFeedbackButtons} so the
- * redesigned task thread speaks the same footer language as the conference
- * room's {@link AgentBubbleActionRow} without re-declaring their markup; the
- * timestamp stays owned by the summary/bubble, so it is not duplicated here.
+ * {@link IssueChatFeedbackButtons} so the redesigned task thread speaks the
+ * same feedback language as the conference room's {@link AgentBubbleActionRow};
+ * the timestamp stays owned by the summary/bubble, so it is not duplicated here.
*/
export function TaskChatBubbleActions({
copyText,
@@ -34,9 +33,26 @@ export function TaskChatBubbleActions({
copyText: string;
feedback?: TaskChatBubbleFeedback | null;
}) {
+ const [copied, setCopied] = useState(false);
+
return (
-
+
{
+ void copyTextToClipboard(copyText)
+ .then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ })
+ .catch(() => {});
+ }}
+ >
+ {copied ? : }
+
{feedback ? (
{
);
});
const buttons = Array.from(container.querySelectorAll("button"));
- const confirm = buttons.find((button) => button.textContent === "Confirm");
- const decline = buttons.find((button) => button.textContent === "Decline");
- expect(confirm).not.toBeUndefined();
- expect(decline).not.toBeUndefined();
- const row = confirm?.parentElement;
- expect(row).toBe(decline?.parentElement);
+ const approve = buttons.find((button) => button.textContent === "Approve");
+ const reject = buttons.find((button) => button.textContent === "Reject");
+ expect(approve).not.toBeUndefined();
+ expect(reject).not.toBeUndefined();
+ const row = approve?.parentElement;
+ expect(row).toBe(reject?.parentElement);
expect(row?.className).toContain("flex-row-reverse");
});
diff --git a/ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx b/ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx
new file mode 100644
index 00000000000..7e30ca08717
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatLiveRunPill.test.tsx
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { TranscriptEntry } from "../../adapters";
+import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "./TaskChatLiveRunPill";
+
+function toolCall(overrides: Partial>): TranscriptEntry {
+ return { kind: "tool_call", ts: "2026-08-08T00:00:00.000Z", name: "read_file", input: {}, ...overrides };
+}
+
+describe("toolCountSummaryFromEntries", () => {
+ it("returns null when there are no tool calls", () => {
+ expect(toolCountSummaryFromEntries([])).toBeNull();
+ expect(
+ toolCountSummaryFromEntries([{ kind: "assistant", ts: "t", text: "hi" } as TranscriptEntry]),
+ ).toBeNull();
+ });
+
+ it("counts commands and other tools separately with pluralization", () => {
+ const entries: TranscriptEntry[] = [
+ toolCall({ name: "bash", input: { command: "ls" }, toolUseId: "c1" }),
+ toolCall({ name: "command_execution", input: { command: "pwd" }, toolUseId: "c2" }),
+ toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
+ ];
+ expect(toolCountSummaryFromEntries(entries)).toBe("ran 2 commands, called 1 tool");
+ });
+
+ it("dedupes re-emitted tool_calls that share a toolUseId", () => {
+ const entries: TranscriptEntry[] = [
+ toolCall({ name: "read_file", input: {}, toolUseId: "t1" }),
+ toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
+ toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
+ ];
+ expect(toolCountSummaryFromEntries(entries)).toBe("called 1 tool");
+ });
+});
+
+describe("TaskChatLiveRunPill", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ });
+
+ it("shimmers 'Working' with elapsed + tool summary while streaming", () => {
+ const startedAtMs = Date.now() - 65_000; // ~1 minute ago
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ const pill = container.querySelector('[data-testid="task-chat-live-run-pill"]');
+ expect(pill).not.toBeNull();
+ const shimmer = container.querySelector(".shimmer-text");
+ expect(shimmer?.textContent).toBe("Working");
+ expect(pill?.textContent).toContain("for 1 minute");
+ expect(pill?.textContent).toContain("called 3 tools");
+ });
+
+ it("settles to a static 'Worked' summary once the run is terminal", () => {
+ const startedAtMs = 1_000;
+ act(() => {
+ root.render(
+ ,
+ );
+ });
+ const pill = container.querySelector('[data-testid="task-chat-live-run-pill"]');
+ expect(container.querySelector(".shimmer-text")).toBeNull();
+ expect(pill?.textContent).toContain("Worked");
+ expect(pill?.textContent).toContain("for 42 seconds");
+ expect(pill?.textContent).toContain("ran 1 command");
+ });
+});
diff --git a/ui/src/components/task-chat/TaskChatLiveRunPill.tsx b/ui/src/components/task-chat/TaskChatLiveRunPill.tsx
new file mode 100644
index 00000000000..ca9fff787dc
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatLiveRunPill.tsx
@@ -0,0 +1,88 @@
+import { Loader2 } from "lucide-react";
+import type { TranscriptEntry } from "../../adapters";
+import { cn } from "@/lib/utils";
+import { useSecondTick } from "@/hooks/useSecondTick";
+import { formatDurationWords } from "@/lib/issue-chat-messages";
+import { isCommandTool } from "@/lib/transcriptPresentation";
+import { isTerminalRunStatus } from "@/components/task-chat/transcript-adapter";
+
+/**
+ * "ran N commands, called M tools" for the live tail's status pill, counted off
+ * the streamed transcript entries. Mirrors IssueChatThread's `toolCountSummary`
+ * (which counts a settled message's tool-call parts) so the experimental live
+ * tail reads the same as the default view. Streaming runtimes re-emit a
+ * tool_call as its status progresses, so calls carrying a `toolUseId` are
+ * counted once.
+ */
+export function toolCountSummaryFromEntries(entries: readonly TranscriptEntry[]): string | null {
+ const seen = new Set();
+ let commands = 0;
+ let other = 0;
+ for (const entry of entries) {
+ if (entry.kind !== "tool_call") continue;
+ if (entry.toolUseId) {
+ if (seen.has(entry.toolUseId)) continue;
+ seen.add(entry.toolUseId);
+ }
+ if (isCommandTool(entry.name, entry.input)) commands += 1;
+ else other += 1;
+ }
+ const parts: string[] = [];
+ if (commands > 0) parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`);
+ if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`);
+ return parts.length > 0 ? parts.join(", ") : null;
+}
+
+/**
+ * Status pill for the experimental chat-style view's live tail (PAP-461, A1).
+ *
+ * Byte-for-byte the same affordance the default view shows above a run's chain
+ * of thought (IssueChatThread's CoT header): a spinner + shimmering "Working"
+ * verb + elapsed words + "· called N tools" while the run streams, settling to
+ * a static emerald dot + "Worked" summary once it terminates. The verb shimmers
+ * via `.shimmer-text` (reduced-motion aware) exactly as the default view does.
+ */
+export function TaskChatLiveRunPill({
+ status,
+ startedAtMs,
+ finishedAtMs,
+ toolSummary,
+}: {
+ status: string;
+ /** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
+ startedAtMs: number | null;
+ /** Run finish in ms once terminal; drives the settled elapsed readout. */
+ finishedAtMs?: number | null;
+ toolSummary: string | null;
+}) {
+ const active = !isTerminalRunStatus(status);
+ // One shared page-wide ticker drives the live elapsed readout, matching the
+ // default view's `useLiveElapsed`.
+ useSecondTick(active && startedAtMs != null);
+
+ const elapsedMs =
+ startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
+ const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null;
+ const verb = active ? "Working" : "Worked";
+ const suffix = elapsed ? `for ${elapsed}` : null;
+
+ return (
+
+
+ {active ? (
+
+ ) : (
+
+
+
+ )}
+ {active ? {verb} : verb}
+
+ {suffix ? {suffix} : null}
+ {toolSummary ? · {toolSummary} : null}
+
+ );
+}
diff --git a/ui/src/components/task-chat/TaskChatLiveTail.test.tsx b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx
new file mode 100644
index 00000000000..7a8523e7f79
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatLiveTail.test.tsx
@@ -0,0 +1,138 @@
+// @vitest-environment jsdom
+
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { TranscriptEntry } from "@/adapters";
+import { ThemeProvider } from "@/context/ThemeContext";
+import { TaskChatLiveTail } from "./TaskChatLiveTail";
+import { transcriptToTaskChatItems } from "./transcript-adapter";
+import type { TaskChatItem } from "./task-chat-model";
+
+const TS = "2026-08-08T12:00:00.000Z";
+
+describe("TaskChatLiveTail", () => {
+ let container: HTMLDivElement;
+ let root: Root | null = null;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ flushSync(() => root?.unmount());
+ root = null;
+ container.remove();
+ });
+
+ function render(items: TaskChatItem[], emptyMessage?: string) {
+ flushSync(() =>
+ root!.render(
+
+
+ ,
+ ),
+ );
+ }
+
+ function parse(entries: TranscriptEntry[], running = true) {
+ return transcriptToTaskChatItems(entries, { runId: "run-1", running });
+ }
+
+ it("renders streamed reply markdown and tool cards from a live transcript", () => {
+ const items = parse([
+ { kind: "assistant", ts: TS, text: "Looking into the failing test." },
+ { kind: "tool_call", ts: TS, name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
+ { kind: "tool_result", ts: TS, toolUseId: "t1", content: "ok", isError: false },
+ ]);
+ render(items);
+
+ expect(container.querySelector('[data-testid="task-chat-live-text"]')?.textContent).toContain(
+ "Looking into the failing test.",
+ );
+ // Tool row renders with its name + mono target.
+ expect(container.textContent).toContain("Read");
+ expect(container.textContent).toContain("src/app.ts");
+ });
+
+ it("renders a tool's diff inset", () => {
+ const items = parse([
+ { kind: "tool_call", ts: TS, name: "Edit", toolUseId: "t1", input: { file_path: "a.ts" } },
+ { kind: "diff", ts: TS, changeType: "add", text: "const x = 1;" },
+ { kind: "diff", ts: TS, changeType: "remove", text: "const x = 0;" },
+ ]);
+ render(items);
+
+ expect(container.textContent).toContain("const x = 1;");
+ expect(container.textContent).toContain("+1 −1");
+ });
+
+ it("drops the debug plumbing kinds RunTranscriptView surfaced", () => {
+ // Feed the exact noise the board flagged: init row, stdout/stderr/system
+ // dumps, and a result line — interleaved with real content. None of the
+ // noise may reach the DOM; only the assistant text + tool row survive.
+ const items = parse([
+ { kind: "init", ts: TS, model: "claude-opus", sessionId: "sess-INITMARKER" },
+ { kind: "system", ts: TS, text: "SYSTEMNOISE hint about the environment" },
+ { kind: "stdout", ts: TS, text: "STDOUTNOISE raw log line" },
+ { kind: "stderr", ts: TS, text: "STDERRNOISE warning" },
+ { kind: "assistant", ts: TS, text: "Here is the real reply." },
+ { kind: "tool_call", ts: TS, name: "Bash", toolUseId: "t1", input: { command: "pnpm test" } },
+ {
+ kind: "result",
+ ts: TS,
+ text: "RESULTNOISE",
+ inputTokens: 10,
+ outputTokens: 5,
+ cachedTokens: 0,
+ costUsd: 0.01,
+ subtype: "success",
+ isError: false,
+ errors: [],
+ },
+ ]);
+ render(items);
+
+ const text = container.textContent ?? "";
+ expect(text).toContain("Here is the real reply.");
+ expect(text).toContain("pnpm test");
+ // No debug plumbing, no RunTranscriptView chrome.
+ for (const noise of [
+ "INITMARKER",
+ "SYSTEMNOISE",
+ "STDOUTNOISE",
+ "STDERRNOISE",
+ "RESULTNOISE",
+ "Streaming",
+ "LOG LINES",
+ "SYSTEM MESSAGES",
+ ]) {
+ expect(text).not.toContain(noise);
+ }
+ });
+
+ it("does not render a thinking row (its signal is the status pill)", () => {
+ const items = parse([
+ { kind: "thinking", ts: TS, text: "SECRET internal reasoning" },
+ { kind: "assistant", ts: TS, text: "Visible answer." },
+ ]);
+ render(items);
+
+ expect(container.textContent).toContain("Visible answer.");
+ expect(container.textContent).not.toContain("SECRET internal reasoning");
+ });
+
+ it("shows the empty message when nothing renderable has streamed yet", () => {
+ render([], "Waiting to start...");
+ expect(container.textContent).toContain("Waiting to start...");
+ });
+
+ it("renders nothing (not even the empty message) once content exists", () => {
+ const items = parse([{ kind: "assistant", ts: TS, text: "streaming…" }]);
+ render(items, "Waiting to start...");
+ expect(container.textContent).not.toContain("Waiting to start...");
+ expect(container.textContent).toContain("streaming…");
+ });
+});
diff --git a/ui/src/components/task-chat/TaskChatLiveTail.tsx b/ui/src/components/task-chat/TaskChatLiveTail.tsx
new file mode 100644
index 00000000000..ba652a0590e
--- /dev/null
+++ b/ui/src/components/task-chat/TaskChatLiveTail.tsx
@@ -0,0 +1,86 @@
+import type { ReactElement } from "react";
+import { MarkdownBody } from "@/components/MarkdownBody";
+import type { TaskChatItem } from "./task-chat-model";
+import { TaskChatToolCard } from "./TaskChatToolCard";
+import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
+
+/**
+ * Live-tail body for the experimental chat-style view (PAP-463, Workstream C1
+ * of PAP-458).
+ *
+ * Renders the in-flight run's streaming transcript as the SAME clean rows the
+ * settled thread uses — tool cards (with diffs) and the streamed reply markdown
+ * — instead of the verbatim `RunTranscriptView` debug viewer that the live tail
+ * used since `e4f3d7733`. The items come from `transcriptToTaskChatItems`, which
+ * already drops the debug plumbing (init / stdout / stderr / system / user /
+ * result), so none of `RunTranscriptView`'s noise can reach the thread: no INIT
+ * row, no "N LOG LINES" / "N SYSTEM MESSAGES" banners, no raw stdout/JSON dumps,
+ * no "Streaming" chip, no uppercase "USED TERMINAL" cards. The status pill above
+ * this body (`TaskChatLiveRunPill`) owns the run-status affordance.
+ *
+ * Live and settle-gap render identically — both feed their parsed items here
+ * (`running: true` while in flight, `false` through the settle gap) — so the
+ * tail never restyles when a run finishes; the hand-off to the folded settled
+ * turn is the only visible transition.
+ */
+export function TaskChatLiveTail({
+ items,
+ emptyMessage,
+}: {
+ items: readonly TaskChatItem[];
+ /** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
+ emptyMessage?: string;
+}) {
+ const rows = items
+ .map((item) => renderTailRow(item))
+ .filter((row): row is ReactElement => row != null);
+
+ if (rows.length === 0) {
+ return emptyMessage ? (
+ {emptyMessage}
+ ) : null;
+ }
+
+ return {rows}
;
+}
+
+function renderTailRow(item: TaskChatItem): ReactElement | null {
+ switch (item.kind) {
+ case "message": {
+ // Streamed reply text (always interstitial from the transcript adapter).
+ // Rendered as plain markdown — the settled thread later replaces it with
+ // the posted comment bubble, so no author header/bubble chrome here.
+ const text = item.text.trim();
+ if (!text) return null;
+ return (
+
+
+ {item.text}
+
+
+ );
+ }
+ case "tool":
+ return (
+
+
+
+ );
+ case "usage":
+ return (
+
+
+
+ );
+ // Thinking never renders as a row (PAP-361): its live signal is the status
+ // pill, and the text stays in the run log / classic transcript. Every other
+ // kind (markers, interactions, briefs, statuses, turns, and the dropped
+ // debug kinds) cannot appear in a parsed live transcript.
+ default:
+ return null;
+ }
+}
diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx
index fa316c9699f..602ffad4fdd 100644
--- a/ui/src/components/task-chat/TaskChatThreadView.tsx
+++ b/ui/src/components/task-chat/TaskChatThreadView.tsx
@@ -40,6 +40,10 @@ interface TaskChatThreadViewProps {
* fixtures omit it and the bubbles render actionless.
*/
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
+ /** Content appended inside the transcript scroller after the settled thread. */
+ tail?: ReactNode;
+ /** Optional streaming-aware key when `tail` changes without changing `items`. */
+ contentKey?: number;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
@@ -128,6 +132,8 @@ export function TaskChatThreadView({
renderInteraction,
renderBrief,
renderMessageActions,
+ tail,
+ contentKey,
className,
scroll = true,
}: TaskChatThreadViewProps) {
@@ -143,12 +149,17 @@ export function TaskChatThreadView({
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
))}
+ {tail}
);
if (!scroll) return body;
- return {body} ;
+ return (
+
+ {body}
+
+ );
}
// Cheap content signature so streaming growth (text lengthening without the
diff --git a/ui/src/components/task-chat/interaction-thread-order.test.ts b/ui/src/components/task-chat/interaction-thread-order.test.ts
new file mode 100644
index 00000000000..b56d64b5b26
--- /dev/null
+++ b/ui/src/components/task-chat/interaction-thread-order.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from "vitest";
+import type {
+ RequestConfirmationInteraction,
+ RequestConfirmationResult,
+} from "@/lib/issue-thread-interactions";
+import {
+ interactionThreadAnchorMs,
+ isSuppressedThreadInteraction,
+} from "./interaction-thread-order";
+
+function confirmation(
+ overrides: Partial = {},
+): RequestConfirmationInteraction {
+ return {
+ id: "confirmation-1",
+ companyId: "company-1",
+ issueId: "issue-1",
+ kind: "request_confirmation",
+ title: "Approve the plan",
+ summary: null,
+ status: "pending",
+ continuationPolicy: "wake_assignee",
+ resolverPolicy: "board_only",
+ requestedResolverPolicy: "board_only",
+ effectiveResolverPolicy: "board_only",
+ createdByAgentId: "agent-1",
+ createdByUserId: null,
+ resolvedByAgentId: null,
+ resolvedByUserId: null,
+ createdAt: new Date("2026-04-06T12:00:00.000Z"),
+ updatedAt: new Date("2026-04-06T12:00:00.000Z"),
+ resolvedAt: null,
+ payload: { version: 1, prompt: "Approve?" },
+ result: null,
+ ...overrides,
+ };
+}
+
+function result(outcome: RequestConfirmationResult["outcome"]): RequestConfirmationResult {
+ return { version: 1, outcome };
+}
+
+const CREATED_MS = new Date("2026-04-06T12:00:00.000Z").getTime();
+const RESOLVED_MS = new Date("2026-04-06T12:05:00.000Z").getTime();
+
+describe("isSuppressedThreadInteraction", () => {
+ it("hides withdrawn confirmations", () => {
+ expect(
+ isSuppressedThreadInteraction(
+ confirmation({ status: "cancelled", result: result("withdrawn") }),
+ ),
+ ).toBe(true);
+ });
+
+ it("hides confirmations superseded by a comment or a newer request", () => {
+ expect(
+ isSuppressedThreadInteraction(
+ confirmation({ status: "expired", result: result("superseded_by_comment") }),
+ ),
+ ).toBe(true);
+ expect(
+ isSuppressedThreadInteraction(
+ confirmation({ status: "expired", result: result("superseded_by_newer_request") }),
+ ),
+ ).toBe(true);
+ });
+
+ it("keeps accepted, rejected, and still-pending confirmations", () => {
+ expect(isSuppressedThreadInteraction(confirmation())).toBe(false);
+ expect(
+ isSuppressedThreadInteraction(
+ confirmation({ status: "accepted", result: result("accepted") }),
+ ),
+ ).toBe(false);
+ expect(
+ isSuppressedThreadInteraction(
+ confirmation({ status: "rejected", result: result("rejected") }),
+ ),
+ ).toBe(false);
+ });
+
+ it("only suppresses confirmation-family kinds, not questions or suggestions", () => {
+ const superseded = { version: 1, outcome: "superseded_by_comment" } as never;
+ expect(
+ isSuppressedThreadInteraction(
+ confirmation({ kind: "ask_user_questions", result: superseded } as never),
+ ),
+ ).toBe(false);
+ expect(
+ isSuppressedThreadInteraction(
+ confirmation({ kind: "suggest_tasks", result: superseded } as never),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe("interactionThreadAnchorMs", () => {
+ it("keeps a pending card at its request-time fallback", () => {
+ expect(interactionThreadAnchorMs(confirmation(), CREATED_MS)).toBe(CREATED_MS);
+ });
+
+ it("re-anchors a resolved card to its resolution time", () => {
+ const resolved = confirmation({
+ status: "accepted",
+ result: result("accepted"),
+ resolvedAt: new Date(RESOLVED_MS),
+ });
+ expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(RESOLVED_MS);
+ });
+
+ it("never drags a resolved card above its own request slot", () => {
+ // Clock skew: resolvedAt reads earlier than the handoff/created fallback.
+ const resolved = confirmation({
+ status: "accepted",
+ result: result("accepted"),
+ resolvedAt: new Date(CREATED_MS - 60_000),
+ });
+ expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(CREATED_MS);
+ });
+
+ it("falls back when a resolved card has no resolvedAt yet", () => {
+ const resolved = confirmation({ status: "accepted", result: result("accepted") });
+ expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(CREATED_MS);
+ });
+});
diff --git a/ui/src/components/task-chat/interaction-thread-order.ts b/ui/src/components/task-chat/interaction-thread-order.ts
new file mode 100644
index 00000000000..0a8f4fcefd2
--- /dev/null
+++ b/ui/src/components/task-chat/interaction-thread-order.ts
@@ -0,0 +1,79 @@
+import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
+
+/**
+ * Thread-ordering + visibility rules for issue-thread interaction cards
+ * (PAP-416, Phase A of PAP-412).
+ *
+ * Two problems this fixes:
+ *
+ * 1. Cards were pinned to `createdAt`, but the user answers them later and the
+ * agent's follow-up lands later still — so a card you just resolved could
+ * sit ABOVE a message written before you answered, reading backwards. We
+ * settle a RESOLVED card at its resolution time (`resolvedAt`) so it never
+ * floats above an earlier message. Pending cards stay where they were asked.
+ *
+ * 2. Withdrawn / superseded confirmation cards lingered in the thread, stacking
+ * a dead card above the accepted one. We suppress those from the backbone
+ * entirely — a retracted or superseded confirmation is not a call to action
+ * and reads as noise next to the outcome that replaced it.
+ */
+
+// The confirmation-family kinds: cards that are a call to act on a proposal.
+// ask_user_questions / suggest_tasks keep their superseded notices (legacy
+// parity) — only confirmations get fully hidden when withdrawn/superseded.
+const CONFIRMATION_KINDS = new Set([
+ "request_confirmation",
+ "request_checkbox_confirmation",
+ "request_item_verdicts",
+]);
+
+// Terminal outcomes that mean "this confirmation was retracted or replaced": it
+// never got an accept/reject decision the reader needs to see. `withdrawn` is an
+// agent/board retraction; the two `superseded_by_*` outcomes fire when a later
+// comment or a fresh request took its place.
+const SUPPRESSED_CONFIRMATION_OUTCOMES = new Set([
+ "withdrawn",
+ "superseded_by_comment",
+ "superseded_by_newer_request",
+]);
+
+function interactionOutcome(interaction: IssueThreadInteraction): string | null {
+ const result = interaction.result;
+ return result && "outcome" in result && typeof result.outcome === "string"
+ ? result.outcome
+ : null;
+}
+
+/**
+ * A confirmation card that was withdrawn or superseded — hide it from the thread
+ * so it never stacks above the confirmation that replaced it.
+ */
+export function isSuppressedThreadInteraction(interaction: IssueThreadInteraction): boolean {
+ if (!CONFIRMATION_KINDS.has(interaction.kind)) return false;
+ const outcome = interactionOutcome(interaction);
+ return outcome != null && SUPPRESSED_CONFIRMATION_OUTCOMES.has(outcome);
+}
+
+function toMs(value: Date | string | null | undefined): number {
+ if (!value) return 0;
+ const ms = new Date(value).getTime();
+ return Number.isNaN(ms) ? 0 : ms;
+}
+
+/**
+ * The chronological slot for an interaction card.
+ *
+ * `fallbackMs` is the caller's existing anchor (the same-run handoff shift when
+ * present, else `createdAt`). A resolved card re-anchors to its resolution time
+ * so it lands next to the answer, never above an earlier message; we take the
+ * later of the two so clock skew can't drag it back above its own request. A
+ * still-pending card keeps the request-time slot.
+ */
+export function interactionThreadAnchorMs(
+ interaction: IssueThreadInteraction,
+ fallbackMs: number,
+): number {
+ if (interaction.status === "pending") return fallbackMs;
+ const resolvedMs = toMs(interaction.resolvedAt);
+ return resolvedMs > 0 ? Math.max(resolvedMs, fallbackMs) : fallbackMs;
+}
diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts
index e465c8044cd..9e60b01660c 100644
--- a/ui/src/components/task-chat/task-chat-model.ts
+++ b/ui/src/components/task-chat/task-chat-model.ts
@@ -1,6 +1,6 @@
/**
- * Normalized presentation model for the Task Chat Redesign (flag:
- * enableTaskChatRedesign).
+ * Normalized presentation model for the chat-style task thread (the default
+ * task view; the classic legacy view sits behind enableClassicTaskInterface).
*
* This is a deliberately small, protocol-agnostic model that the new render
* layer consumes. Two producers feed it:
diff --git a/ui/src/components/task-chat/task-chat-states.ts b/ui/src/components/task-chat/task-chat-states.ts
index 01fafc2d452..0b808fda542 100644
--- a/ui/src/components/task-chat/task-chat-states.ts
+++ b/ui/src/components/task-chat/task-chat-states.ts
@@ -1,6 +1,6 @@
/**
- * Canonical state inventory for the Task Chat Redesign (flag:
- * enableTaskChatRedesign).
+ * Canonical state inventory for the chat-style task thread (the default task
+ * view; the classic legacy view sits behind enableClassicTaskInterface).
*
* This list is the single source of truth for:
* - the dev harness state switcher (/dev/task-chat-lab), and
diff --git a/ui/src/components/transcript/RunTranscriptView.test.tsx b/ui/src/components/transcript/RunTranscriptView.test.tsx
index 4e980c11e0f..1639fb3c674 100644
--- a/ui/src/components/transcript/RunTranscriptView.test.tsx
+++ b/ui/src/components/transcript/RunTranscriptView.test.tsx
@@ -6,7 +6,7 @@ import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
import { buildTranscript, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import type { ToolRunDecision } from "@paperclipai/shared";
import { ThemeProvider } from "../../context/ThemeContext";
-import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
+import { RunTranscriptView, keyTranscriptBlocks, normalizeTranscript } from "./RunTranscriptView";
describe("RunTranscriptView", () => {
it("folds repeated tool_call status updates for the same toolUseId into one block", () => {
@@ -322,4 +322,48 @@ describe("RunTranscriptView", () => {
expect(html).not.toContain("line-250");
expect(html).not.toContain("line-499");
});
+
+ it("keeps the streaming tail block's key stable as deltas merge in", () => {
+ // A streaming assistant message accumulates deltas; each delta advances the
+ // block's `ts`. The React key must stay anchored to the block's opening
+ // timestamp so the tail does not unmount/remount (restarting its fade).
+ const firstDelta: TranscriptEntry[] = [
+ { kind: "assistant", ts: "2026-03-12T00:00:00.000Z", text: "Hel", delta: true },
+ ];
+ const withMoreDeltas: TranscriptEntry[] = [
+ ...firstDelta,
+ { kind: "assistant", ts: "2026-03-12T00:00:00.400Z", text: "lo the", delta: true },
+ { kind: "assistant", ts: "2026-03-12T00:00:00.900Z", text: "re", delta: true },
+ ];
+
+ const keyOf = (entries: TranscriptEntry[]) => {
+ const keyed = keyTranscriptBlocks(normalizeTranscript(entries, true));
+ return keyed[keyed.length - 1]!.key;
+ };
+
+ // Same opening timestamp → identical key even though `ts` advanced.
+ expect(keyOf(withMoreDeltas)).toBe(keyOf(firstDelta));
+ // And that key is anchored to the opening ts, not the latest one.
+ expect(keyOf(withMoreDeltas)).toContain("2026-03-12T00:00:00.000Z");
+ expect(keyOf(withMoreDeltas)).not.toContain("2026-03-12T00:00:00.900Z");
+ });
+
+ it("assigns unique keys and does not remount earlier blocks when a new tail arrives", () => {
+ const base: TranscriptEntry[] = [
+ { kind: "assistant", ts: "2026-03-12T00:00:00.000Z", text: "first message", delta: true },
+ { kind: "thinking", ts: "2026-03-12T00:00:01.000Z", text: "pondering", delta: true },
+ ];
+ const withNewTail: TranscriptEntry[] = [
+ ...base,
+ { kind: "assistant", ts: "2026-03-12T00:00:02.000Z", text: "second message", delta: true },
+ ];
+
+ const baseKeys = keyTranscriptBlocks(normalizeTranscript(base, true)).map((b) => b.key);
+ const nextKeys = keyTranscriptBlocks(normalizeTranscript(withNewTail, true)).map((b) => b.key);
+
+ // Keys are unique within a render.
+ expect(new Set(nextKeys).size).toBe(nextKeys.length);
+ // Existing blocks keep their identity when a new block appends after them.
+ expect(nextKeys.slice(0, baseKeys.length)).toEqual(baseKeys);
+ });
});
diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx
index 9ec7c192273..5259c23497a 100644
--- a/ui/src/components/transcript/RunTranscriptView.tsx
+++ b/ui/src/components/transcript/RunTranscriptView.tsx
@@ -48,12 +48,18 @@ type TranscriptBlock =
type: "message";
role: "assistant" | "user";
ts: string;
+ // Timestamp of the first entry that opened this block. `ts` tracks the
+ // latest merged delta and mutates every chunk; `startTs` stays fixed so
+ // the React key is stable and the streaming block does not remount (and
+ // restart its fade) on each delta.
+ startTs: string;
text: string;
streaming: boolean;
}
| {
type: "thinking";
ts: string;
+ startTs: string;
text: string;
streaming: boolean;
}
@@ -73,6 +79,7 @@ type TranscriptBlock =
| {
type: "activity";
ts: string;
+ startTs: string;
activityId?: string;
name: string;
status: "running" | "completed";
@@ -125,6 +132,7 @@ type TranscriptBlock =
| {
type: "stdout";
ts: string;
+ startTs: string;
text: string;
}
| {
@@ -550,6 +558,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
type: "message",
role: entry.kind,
ts: entry.ts,
+ startTs: entry.ts,
text: entry.text,
streaming: isStreaming,
});
@@ -567,6 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
blocks.push({
type: "thinking",
ts: entry.ts,
+ startTs: entry.ts,
text: entry.text,
streaming: isStreaming,
});
@@ -691,6 +701,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
const block: Extract = {
type: "activity",
ts: entry.ts,
+ startTs: entry.ts,
activityId: activity.activityId,
name: activity.name,
status: activity.status,
@@ -758,6 +769,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
blocks.push({
type: "stdout",
ts: entry.ts,
+ startTs: entry.ts,
text: entry.text,
});
}
@@ -766,6 +778,55 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
return groupToolBlocks(groupCommandBlocks(blocks));
}
+/**
+ * Stable identity for a block's React key. Anchored to the block's opening
+ * timestamp (`startTs`) or a durable id (`toolUseId`, `activityId`) rather than
+ * its latest `ts`, which mutates on every streamed delta. A mutating key
+ * remounts the block, restarting its 300ms fade-in so the text visibly blinks
+ * out and back each chunk; a stable one keeps the streaming tail mounted.
+ */
+function transcriptBlockIdentity(block: TranscriptBlock): string {
+ switch (block.type) {
+ case "message":
+ return `message:${block.role}:${block.startTs}`;
+ case "thinking":
+ return `thinking:${block.startTs}`;
+ case "stdout":
+ return `stdout:${block.startTs}`;
+ case "activity":
+ return `activity:${block.activityId ?? block.startTs}`;
+ case "tool":
+ return `tool:${block.toolUseId ?? block.ts}`;
+ case "command_group":
+ return `command_group:${block.ts}`;
+ case "tool_group":
+ return `tool_group:${block.ts}`;
+ case "stderr_group":
+ return `stderr_group:${block.ts}`;
+ case "system_group":
+ return `system_group:${block.ts}`;
+ case "diff_group":
+ return `diff_group:${block.ts}`;
+ case "event":
+ return `event:${block.label}:${block.ts}`;
+ }
+}
+
+/**
+ * Assign each block a stable, unique React key. Identity is position-independent
+ * so earlier blocks collapsing (truncation) does not remount the survivors; a
+ * per-render occurrence counter disambiguates the rare identity collision.
+ */
+export function keyTranscriptBlocks(blocks: TranscriptBlock[]): Array<{ block: TranscriptBlock; key: string }> {
+ const seen = new Map();
+ return blocks.map((block) => {
+ const identity = transcriptBlockIdentity(block);
+ const occurrence = seen.get(identity) ?? 0;
+ seen.set(identity, occurrence + 1);
+ return { block, key: occurrence === 0 ? identity : `${identity}#${occurrence}` };
+ });
+}
+
function TranscriptMessageBlock({
block,
density,
@@ -789,7 +850,9 @@ function TranscriptMessageBlock({
*:first-child]:mt-0 [&>*:last-child]:mb-0",
- compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
+ // Match the default view's chat message body (IssueChatThread:
+ // `text-sm leading-6`) so streamed text reads identically (PAP-461, A2).
+ compact ? "text-xs leading-5 text-foreground/85" : "text-sm leading-6",
)}
externalReferences={externalReferences}
>
@@ -798,7 +861,7 @@ function TranscriptMessageBlock({
{block.streaming && (
-
+
Streaming
@@ -822,8 +885,11 @@ function TranscriptThinkingBlock({
return (
*:first-child]:mt-0 [&>*:last-child]:mb-0",
- density === "compact" ? "text-(length:--text-micro) leading-5" : "text-sm leading-6",
+ // Match the default view's chain-of-thought text (IssueChatThread:
+ // `text-(length:--text-compact) italic leading-5 text-muted-foreground/70`)
+ // so streamed thinking reads identically across both views (PAP-461, A2).
+ "italic text-muted-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
+ density === "compact" ? "text-(length:--text-micro) leading-5" : "text-(length:--text-compact) leading-5",
className,
)}
externalReferences={externalReferences}
@@ -1304,7 +1370,7 @@ function TranscriptActivityRow({
) : (
-
+
)}
@@ -1716,6 +1782,7 @@ export function RunTranscriptView({
[entries, mode, streaming],
);
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
+ const keyedBlocks = useMemo(() => keyTranscriptBlocks(visibleBlocks), [visibleBlocks]);
const visibleEntries = limit ? entries.slice(-limit) : entries;
if (entries.length === 0) {
@@ -1736,10 +1803,10 @@ export function RunTranscriptView({
return (
- {visibleBlocks.map((block, index) => (
+ {keyedBlocks.map(({ block, key }, index) => (
{block.type === "message" && (
{
});
container.remove();
});
+
+ it("retains an accumulated buffer through a transient empty poll (PAP-462 B3)", async () => {
+ const ts = "2026-08-08T00:00:00.000Z";
+ const row = JSON.stringify({
+ ts,
+ stream: "stdout",
+ chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
+ seq: 1,
+ });
+ // Serve the buffered chunk on the FIRST persisted-log read only; every later
+ // read (including after the run reappears) returns nothing new. So the chunk
+ // can only be present the second time if the buffer survived the gap.
+ logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
+ logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
+
+ const captured: { value: ReturnType | null } = { value: null };
+ function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
+ captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
+ return null;
+ }
+
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
+
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+ expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
+
+ // Transient empty poll: run momentarily absent from the list.
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+
+ // Run reappears within the grace window — the buffer must survive rather than
+ // re-hydrate from a (now empty) truncated read.
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+ expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
+
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ });
+
+ it("prunes a buffer once a run stays absent past the grace window (PAP-462 B3)", async () => {
+ vi.useFakeTimers();
+ try {
+ const ts = "2026-08-08T00:00:00.000Z";
+ const row = JSON.stringify({
+ ts,
+ stream: "stdout",
+ chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
+ seq: 1,
+ });
+ logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
+ logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
+
+ const captured: { value: ReturnType | null } = { value: null };
+ function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
+ captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
+ return null;
+ }
+
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
+
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+ expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
+
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+
+ // Stay absent long enough for the grace window to lapse and the deferred
+ // prune to fire.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(25_000);
+ });
+
+ // On reappear the buffer is gone, so the (now empty) read rebuilds nothing.
+ await act(async () => {
+ root.render( );
+ await Promise.resolve();
+ });
+ expect(captured.value?.transcriptByRun.get("run-1") ?? []).toHaveLength(0);
+
+ act(() => {
+ root.unmount();
+ });
+ container.remove();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts
index 0b2e9de75ea..4eaa5dbffba 100644
--- a/ui/src/components/transcript/useLiveRunTranscripts.ts
+++ b/ui/src/components/transcript/useLiveRunTranscripts.ts
@@ -11,6 +11,7 @@ import {
mergeRunLogChunks,
parsePersistedLogContent,
readChunkSeq,
+ type ChunkRetentionBudget,
} from "../../lib/run-log-chunks";
// TODO(perf): this whole hook polls the log/runs endpoints on an interval. The
@@ -24,6 +25,20 @@ const LOG_READ_LIMIT_BYTES = 256_000;
// gaps and reconnects instead of polling every couple of seconds.
const REALTIME_FALLBACK_POLL_INTERVAL_MS = 30_000;
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
+// Retained transcript payload budget for full task views. A byte budget (rather
+// than a tiny chunk count) keeps the whole streamed scrollback intact — a
+// delta-streaming run emits thousands of one-token chunks in seconds, and the
+// old 200-chunk cap discarded just-rendered messages off the top irreversibly.
+// If a run genuinely exceeds this, the oldest output collapses behind a visible
+// marker instead of vanishing (see `applyRetentionBudget`).
+const TASK_VIEW_MAX_BYTES_PER_RUN = 2_000_000;
+// Grace period before an accumulated transcript buffer is pruned for a run that
+// has vanished from the `runs` list. The parent refetches runs on its own
+// interval, and a single transient empty/errored poll would otherwise wipe the
+// buffer — forcing a rehydration that skips to the last `LOG_READ_LIMIT_BYTES`
+// and silently drops already-rendered scrollback (PAP-462 B3). A run that is
+// genuinely gone stays absent past this window and is then pruned as before.
+const RUN_ABSENCE_PRUNE_GRACE_MS = 20_000;
export interface RunTranscriptSource {
id: string;
@@ -37,7 +52,13 @@ export interface RunTranscriptSource {
interface UseLiveRunTranscriptsOptions {
runs: RunTranscriptSource[];
companyId?: string | null;
+ /**
+ * Compact chunk-count cap for ticker-style consumers (dashboard). When set,
+ * trimming is silent — the historical behavior. Full task views omit this and
+ * use the byte budget below instead.
+ */
maxChunksPerRun?: number;
+ maxBytesPerRun?: number;
logPollIntervalMs?: number;
logReadLimitBytes?: number;
enableRealtimeUpdates?: boolean;
@@ -67,11 +88,21 @@ export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: nu
export function useLiveRunTranscripts({
runs,
companyId,
- maxChunksPerRun = 200,
+ maxChunksPerRun,
+ maxBytesPerRun = TASK_VIEW_MAX_BYTES_PER_RUN,
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
enableRealtimeUpdates = true,
}: UseLiveRunTranscriptsOptions) {
+ // Ticker consumers opt into the silent chunk-count cap; full task views use a
+ // byte budget that collapses (not discards) the oldest output when exceeded.
+ const retentionBudget: ChunkRetentionBudget = useMemo(
+ () =>
+ typeof maxChunksPerRun === "number"
+ ? { maxChunks: maxChunksPerRun }
+ : { maxBytes: maxBytesPerRun, collapseTrimmed: true },
+ [maxChunksPerRun, maxBytesPerRun],
+ );
const runsKey = useMemo(
() =>
runs
@@ -95,6 +126,14 @@ export function useLiveRunTranscripts({
const pendingLogRowsByRunRef = useRef(new Map());
const logOffsetByRunRef = useRef(new Map());
const missingTerminalLogRunIdsRef = useRef(new Set());
+ // PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the
+ // wall-clock deadline (ms) after which their buffer may be pruned. A run still
+ // inside its grace window is retained across the empty poll; `pruneTick` fires
+ // the effect again once the nearest deadline elapses so a run that stays gone
+ // is eventually cleaned up even if the `runs` list never changes again.
+ const absenceDeadlineByRunRef = useRef(new Map());
+ const prevKnownRunIdsRef = useRef(new Set());
+ const [pruneTick, setPruneTick] = useState(0);
const transcriptCacheRef = useRef(new Map {
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
+ const now = Date.now();
+ const deadlines = absenceDeadlineByRunRef.current;
+
+ // PAP-462 B3: a run that just disappeared from the list starts its grace
+ // clock; one that reappeared clears any pending deadline. Comparing against
+ // the previous known set means a transient empty poll only *arms* the timer
+ // rather than pruning the buffer outright.
+ for (const runId of prevKnownRunIdsRef.current) {
+ if (!knownRunIds.has(runId) && !deadlines.has(runId)) {
+ deadlines.set(runId, now + RUN_ABSENCE_PRUNE_GRACE_MS);
+ }
+ }
+ for (const runId of knownRunIds) {
+ deadlines.delete(runId);
+ }
+ prevKnownRunIdsRef.current = knownRunIds;
+
+ // Retain known runs plus any absent run still inside its grace window; only
+ // runs absent past their deadline are actually pruned.
+ const retainedRunIds = new Set(knownRunIds);
+ let soonestExpiryMs = Number.POSITIVE_INFINITY;
+ for (const [runId, deadline] of deadlines) {
+ if (deadline > now) {
+ retainedRunIds.add(runId);
+ soonestExpiryMs = Math.min(soonestExpiryMs, deadline);
+ } else {
+ deadlines.delete(runId);
+ }
+ }
+
setChunksByRun((prev) => {
const next = new Map();
for (const [runId, chunks] of prev) {
- if (knownRunIds.has(runId)) {
+ if (retainedRunIds.has(runId)) {
next.set(runId, chunks);
}
}
@@ -157,7 +226,7 @@ export function useLiveRunTranscripts({
setHydratedRunIds((prev) => {
const next = new Set();
for (const runId of prev) {
- if (knownRunIds.has(runId)) {
+ if (retainedRunIds.has(runId)) {
next.add(runId);
}
}
@@ -166,31 +235,41 @@ export function useLiveRunTranscripts({
for (const key of pendingLogRowsByRunRef.current.keys()) {
const runId = key.replace(/:records$/, "");
- if (!knownRunIds.has(runId)) {
+ if (!retainedRunIds.has(runId)) {
pendingLogRowsByRunRef.current.delete(key);
}
}
for (const runId of logOffsetByRunRef.current.keys()) {
- if (!knownRunIds.has(runId)) {
+ if (!retainedRunIds.has(runId)) {
logOffsetByRunRef.current.delete(runId);
}
}
for (const runId of trimmedSeqFloorByRunRef.current.keys()) {
- if (!knownRunIds.has(runId)) {
+ if (!retainedRunIds.has(runId)) {
trimmedSeqFloorByRunRef.current.delete(runId);
}
}
for (const runId of missingTerminalLogRunIdsRef.current.keys()) {
- if (!knownRunIds.has(runId)) {
+ if (!retainedRunIds.has(runId)) {
missingTerminalLogRunIdsRef.current.delete(runId);
}
}
for (const runId of transcriptCacheRef.current.keys()) {
- if (!knownRunIds.has(runId)) {
+ if (!retainedRunIds.has(runId)) {
transcriptCacheRef.current.delete(runId);
}
}
- }, [normalizedRuns]);
+
+ // Re-run once the nearest grace window elapses so a run that stays gone is
+ // pruned even if `normalizedRuns` never changes again.
+ if (soonestExpiryMs !== Number.POSITIVE_INFINITY) {
+ const timer = window.setTimeout(
+ () => setPruneTick((tick) => tick + 1),
+ Math.max(0, soonestExpiryMs - now) + 50,
+ );
+ return () => window.clearTimeout(timer);
+ }
+ }, [normalizedRuns, pruneTick]);
useEffect(() => {
if (normalizedRuns.length === 0) return;
diff --git a/ui/src/fixtures/issueThreadInteractionFixtures.ts b/ui/src/fixtures/issueThreadInteractionFixtures.ts
index 7374709b72b..3d3180f02af 100644
--- a/ui/src/fixtures/issueThreadInteractionFixtures.ts
+++ b/ui/src/fixtures/issueThreadInteractionFixtures.ts
@@ -287,7 +287,7 @@ function createRequestCheckboxConfirmationInteraction(
minSelected: 0,
maxSelected: null,
acceptLabel: "Delete selected",
- rejectLabel: "Request changes",
+ rejectLabel: "Reject",
rejectRequiresReason: false,
},
result: null,
@@ -358,6 +358,45 @@ export const rejectedSuggestedTasksInteraction = createSuggestTasksInteraction({
export const pendingAskUserQuestionsInteraction = createAskUserQuestionsInteraction({});
+/**
+ * A pending question whose last option is a first-class free-text choice
+ * (`freeText: true`). Selecting it reveals an inline text field instead of
+ * acting as a dead radio, and the built-in "Other" link is suppressed
+ * (PAP-419).
+ */
+export const pendingAskUserQuestionsWithFreeTextOption = createAskUserQuestionsInteraction({
+ id: "interaction-questions-freetext",
+ payload: {
+ version: 1,
+ title: "How should we name the new surface?",
+ submitLabel: "Send answers",
+ questions: [
+ {
+ id: "surface-name",
+ prompt: "What should we call the new surface?",
+ selectionMode: "single",
+ required: true,
+ options: [
+ {
+ id: "keep-tasks",
+ label: "Keep calling it Tasks",
+ },
+ {
+ id: "rename-work",
+ label: "Rename it Work",
+ },
+ {
+ id: "describe-it",
+ label: "I'll describe it",
+ description: "Tell us the exact name you have in mind.",
+ freeText: true,
+ },
+ ],
+ },
+ ],
+ },
+});
+
export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInteraction({
id: "interaction-questions-answered",
status: "answered",
@@ -459,7 +498,7 @@ export const planApprovalAcceptedRequestConfirmationInteraction = createRequestC
version: 1,
prompt: "Approve the plan and let the responsible start implementation?",
acceptLabel: "Approve plan",
- rejectLabel: "Request changes",
+ rejectLabel: "Reject",
rejectRequiresReason: true,
declineReasonPlaceholder: "Optional: what would you like revised?",
target: {
@@ -882,7 +921,7 @@ export const manyOptionsRequestCheckboxConfirmationInteraction =
minSelected: 0,
maxSelected: null,
acceptLabel: "Archive selected",
- rejectLabel: "Request changes",
+ rejectLabel: "Reject",
rejectRequiresReason: false,
},
});
@@ -928,7 +967,7 @@ export const staleTargetRequestCheckboxConfirmationInteraction =
version: 1,
prompt: "Check the draft documents you want me to delete.",
acceptLabel: "Delete selected",
- rejectLabel: "Request changes",
+ rejectLabel: "Reject",
options: [
{ id: "draft-march-report", label: "Old draft report" },
{ id: "draft-spec-v1", label: "Spec v1 (superseded)" },
diff --git a/ui/src/hooks/useTaskChatRedesignEnabled.ts b/ui/src/hooks/useClassicTaskInterfaceEnabled.ts
similarity index 62%
rename from ui/src/hooks/useTaskChatRedesignEnabled.ts
rename to ui/src/hooks/useClassicTaskInterfaceEnabled.ts
index 48fdc2f9707..da3773f5eeb 100644
--- a/ui/src/hooks/useTaskChatRedesignEnabled.ts
+++ b/ui/src/hooks/useClassicTaskInterfaceEnabled.ts
@@ -16,19 +16,16 @@ function getDetachedClient(): QueryClient {
}
/**
- * Task Chat Redesign experimental flag.
+ * Classic Task Interface experimental flag (`enableClassicTaskInterface`).
*
* Wraps the shared experimental-settings query so gated call sites don't
- * repeat the boilerplate. `enabled` stays false while the query is in flight
- * (no flash of gated UI); `loaded` lets route gates avoid redirecting away
- * before the flag value is actually known.
- *
- * Renders without a QueryClientProvider resolve to the flag-off default
- * (`{ enabled: false, loaded: true }`) instead of throwing, so widely shared
- * leaf components (e.g. the task detail thread) stay mountable in isolation —
- * and, critically, flag-off is provably the current behavior.
+ * repeat the boilerplate. Fail-closed to chat-style: `enabled` stays false
+ * while the query is in flight, on fetch errors, and in renders without a
+ * QueryClientProvider (isolated unit-test mounts), so the default chat-style
+ * view is what renders unless the classic opt-in is provably on. `loaded`
+ * lets hosts that care distinguish "flag is off" from "flag not yet known".
*/
-export function useTaskChatRedesignEnabled(): { enabled: boolean; loaded: boolean } {
+export function useClassicTaskInterfaceEnabled(): { enabled: boolean; loaded: boolean } {
const contextClient = useContext(QueryClientContext);
const { data, isFetched } = useQuery(
{
@@ -41,5 +38,5 @@ export function useTaskChatRedesignEnabled(): { enabled: boolean; loaded: boolea
if (!contextClient) {
return { enabled: false, loaded: true };
}
- return { enabled: data?.enableTaskChatRedesign === true, loaded: isFetched };
+ return { enabled: data?.enableClassicTaskInterface === true, loaded: isFetched };
}
diff --git a/ui/src/index.css b/ui/src/index.css
index b68251e0c70..9688fb540b4 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -196,7 +196,8 @@
--sz-tweak-panel-max: 70vh; /* Task Chat Redesign dev tweak panel scroll cap. */
/* ────────────────────────────────────────────────────────────────────────
- Motion tokens — Task Chat Redesign (flag: enableTaskChatRedesign).
+ Motion tokens — chat-style task thread (default; classic legacy view sits
+ behind enableClassicTaskInterface).
These are the single source of truth for the redesigned thread's motion.
Kept in :root (NOT @theme inline) on purpose: @theme inline bakes literals
@@ -548,7 +549,8 @@
}
/* ────────────────────────────────────────────────────────────────────────
- Task Chat Redesign animation utilities (flag: enableTaskChatRedesign).
+ Chat-style task thread animation utilities (default view; classic legacy
+ view sits behind enableClassicTaskInterface).
These classes are the ONLY place the redesigned thread's motion is applied;
every duration/easing is a var(--motion-*) reference (never a literal), so
@@ -841,6 +843,35 @@
}
}
+/* RunTranscriptView live-tail motion (PAP-461, A3). The experimental chat-style
+ view streams its live output through RunTranscriptView; these token-driven
+ classes replace the raw Tailwind `animate-in … duration-300` / `animate-ping`
+ the tail used so its streaming affordances honor --motion-* retuning AND the
+ reduced-motion override below, exactly like the rest of the redesign. */
+@keyframes tc-stream-block-in {
+ from { opacity: 0; transform: translateY(0.25rem); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.tc-stream-block-enter {
+ animation: tc-stream-block-in var(--motion-tool-enter) var(--motion-ease-standard) both;
+}
+
+@keyframes tc-live-ping {
+ 75%, 100% { transform: scale(2); opacity: 0; }
+}
+
+.tc-live-ping {
+ animation: tc-live-ping var(--motion-approval-pulse) var(--motion-ease-standard) infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .tc-stream-block-enter,
+ .tc-live-ping {
+ animation: none;
+ }
+}
+
/* Shimmer text effect for active "Working" state — Cursor-style sweep */
@keyframes shimmer-text-slide {
0% { background-position: 100% center; }
diff --git a/ui/src/lib/ceo-instructions.ts b/ui/src/lib/ceo-instructions.ts
index 2ffecaaf66c..f209546caa8 100644
--- a/ui/src/lib/ceo-instructions.ts
+++ b/ui/src/lib/ceo-instructions.ts
@@ -42,7 +42,7 @@ export function composeCeoInstructions(input: ComposeCeoInstructionsInput): stri
return `# Role
-You are the lead agent for ${companyName}. You report to the person who set up this team — they may be a solo founder, a manager inside a larger org, or one of several people each running their own team of agents. Most people call this role CEO — that's fine, and it's your default name.
+You are the Paperclip agent for ${companyName}. You report to the person who set up this team — they may be a solo founder, a manager inside a larger org, or one of several people each running their own team of agents. Work as their lead agent: understand what they're trying to accomplish, propose a plan, and coordinate the work.
Work with the user conversationally. Propose, don't decide. When the user asks for something concrete (a brief, a hiring plan, a roadmap, a pitch), produce a real artifact — save it as a document on the relevant task so they can review and approve.
diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts
index 169da08f5f9..88d3187c2b0 100644
--- a/ui/src/lib/issue-chat-messages.test.ts
+++ b/ui/src/lib/issue-chat-messages.test.ts
@@ -10,6 +10,8 @@ import {
type IssueChatLinkedRun,
} from "./issue-chat-messages";
import type {
+ AskUserQuestionsInteraction,
+ AskUserQuestionsQuestion,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "./issue-thread-interactions";
@@ -719,6 +721,74 @@ describe("buildIssueChatMessages", () => {
});
});
+ it("drops degenerate ask_user_questions interactions so they leave no empty slot (PAP-424)", () => {
+ function askInteraction(
+ id: string,
+ questions: AskUserQuestionsQuestion[],
+ ): AskUserQuestionsInteraction {
+ return {
+ id,
+ companyId: "company-1",
+ issueId: "issue-1",
+ kind: "ask_user_questions",
+ title: null,
+ summary: null,
+ status: "pending",
+ continuationPolicy: "wake_assignee",
+ createdByAgentId: "agent-1",
+ createdByUserId: null,
+ resolvedByAgentId: null,
+ resolvedByUserId: null,
+ createdAt: new Date("2026-04-06T12:02:00.000Z"),
+ updatedAt: new Date("2026-04-06T12:02:00.000Z"),
+ resolvedAt: null,
+ resolverPolicy: "board_only",
+ requestedResolverPolicy: "board_only",
+ effectiveResolverPolicy: "board_only",
+ payload: { version: 1, questions },
+ result: null,
+ } as AskUserQuestionsInteraction;
+ }
+
+ const messages = buildIssueChatMessages({
+ comments: [
+ createComment({
+ id: "comment-1",
+ createdAt: new Date("2026-04-06T12:01:00.000Z"),
+ updatedAt: new Date("2026-04-06T12:01:00.000Z"),
+ }),
+ ],
+ interactions: [
+ // A truly unanswerable card (no options, no free-text) — must be
+ // filtered out entirely.
+ askInteraction("interaction-degenerate", [
+ { id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
+ ]),
+ // A legitimate yes/no question survives.
+ askInteraction("interaction-legit", [
+ {
+ id: "q1",
+ prompt: "Ship it?",
+ selectionMode: "single",
+ options: [
+ { id: "yes", label: "Yes" },
+ { id: "no", label: "No" },
+ ],
+ },
+ ]),
+ ],
+ timelineEvents: [],
+ linkedRuns: [],
+ liveRuns: [],
+ currentUserId: "user-1",
+ });
+
+ const ids = messages.map((message) => `${message.role}:${message.id}`);
+ // The legit card is present; the degenerate one leaves no message at all.
+ expect(ids).toEqual(["user:comment-1", "system:interaction:interaction-legit"]);
+ expect(ids).not.toContain("system:interaction:interaction-degenerate");
+ });
+
it("preserves ephemeral active-run status metadata for rendering", () => {
const activeRun: ActiveRunForIssue = {
id: "run-active-1",
diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts
index 84ccc7cb171..f1aec74fe0e 100644
--- a/ui/src/lib/issue-chat-messages.ts
+++ b/ui/src/lib/issue-chat-messages.ts
@@ -13,6 +13,7 @@ import { formatAssigneeUserLabel } from "./assignees";
import { isOperatorInterruptedRun } from "./interrupt-handoff";
import {
buildIssueThreadInteractionSummary,
+ shouldHideInteractionCard,
type IssueThreadInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
@@ -1091,6 +1092,11 @@ export function buildIssueChatMessages(args: {
}
for (const interaction of sortByCreated(interactions)) {
+ // A card IssueThreadInteractionCard never renders — a degenerate
+ // `ask_user_questions` (e.g. the onboarding `Test / A` placeholder) or a
+ // stale sibling superseded by a newer question (PAP-437) — is skipped here so
+ // it leaves no empty message slot in the thread (PAP-424, plan from PAP-420).
+ if (shouldHideInteractionCard(interaction)) continue;
const createdAtMs = toTimestamp(interaction.createdAt);
const handoffAtMs = interaction.kind === "request_confirmation" && interaction.sourceRunId
? latestSameRunHandoffTimestamp({
diff --git a/ui/src/lib/issue-thread-interactions.test.ts b/ui/src/lib/issue-thread-interactions.test.ts
index ed4310d3766..68c559e882e 100644
--- a/ui/src/lib/issue-thread-interactions.test.ts
+++ b/ui/src/lib/issue-thread-interactions.test.ts
@@ -8,9 +8,17 @@ import {
getItemVerdictProgress,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels,
+ isDegenerateAskUserQuestions,
+ isSupersededByNewerSiblingInteraction,
+ shouldHideInteractionCard,
normalizeRequestConfirmationTargetHref,
} from "./issue-thread-interactions";
-import type { RequestItemVerdictsInteraction } from "./issue-thread-interactions";
+import type {
+ AskUserQuestionsInteraction,
+ AskUserQuestionsQuestion,
+ IssueThreadInteraction,
+ RequestItemVerdictsInteraction,
+} from "./issue-thread-interactions";
const resolverPolicyFields = {
resolverPolicy: "board_only",
@@ -373,3 +381,211 @@ describe("per-item verdict helpers", () => {
}))).toBe("Verdicts expired after comment");
});
});
+
+describe("isDegenerateAskUserQuestions", () => {
+ function askInteraction(
+ questions: AskUserQuestionsQuestion[],
+ ): AskUserQuestionsInteraction {
+ return {
+ id: "interaction-ask",
+ companyId: "company-1",
+ issueId: "issue-1",
+ kind: "ask_user_questions",
+ status: "pending",
+ continuationPolicy: "wake_assignee",
+ ...resolverPolicyFields,
+ createdAt: "2026-04-06T12:00:00.000Z",
+ updatedAt: "2026-04-06T12:00:00.000Z",
+ payload: { version: 1, questions },
+ } as AskUserQuestionsInteraction;
+ }
+
+ it("flags a card with zero questions", () => {
+ expect(isDegenerateAskUserQuestions(askInteraction([]))).toBe(true);
+ });
+
+ it("flags a question with a blank / whitespace-only prompt", () => {
+ expect(isDegenerateAskUserQuestions(askInteraction([
+ {
+ id: "q1",
+ prompt: " ",
+ selectionMode: "single",
+ options: [
+ { id: "a", label: "A" },
+ { id: "b", label: "B" },
+ ],
+ },
+ ]))).toBe(true);
+ });
+
+ it("keeps a single fixed-option question: it is answerable (select + submit)", () => {
+ // A lone fixed option is still resolvable — the user selects it and submits —
+ // so it must render. Hiding it would strand a pending interaction the
+ // assignee is waiting on.
+ expect(isDegenerateAskUserQuestions(askInteraction([
+ {
+ id: "q1",
+ prompt: "Test",
+ selectionMode: "single",
+ options: [{ id: "a", label: "A" }],
+ },
+ ]))).toBe(false);
+ });
+
+ it("flags a question with no options at all", () => {
+ expect(isDegenerateAskUserQuestions(askInteraction([
+ { id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
+ ]))).toBe(true);
+ });
+
+ it("keeps a legitimate yes/no question (2 options)", () => {
+ expect(isDegenerateAskUserQuestions(askInteraction([
+ {
+ id: "q1",
+ prompt: "Ship it?",
+ selectionMode: "single",
+ options: [
+ { id: "yes", label: "Yes" },
+ { id: "no", label: "No" },
+ ],
+ },
+ ]))).toBe(false);
+ });
+
+ it("keeps a multi-select question (>= 2 options)", () => {
+ expect(isDegenerateAskUserQuestions(askInteraction([
+ {
+ id: "q1",
+ prompt: "Pick platforms",
+ selectionMode: "multi",
+ options: [
+ { id: "web", label: "Web" },
+ { id: "ios", label: "iOS" },
+ { id: "android", label: "Android" },
+ ],
+ },
+ ]))).toBe(false);
+ });
+
+ it("keeps a question whose only choice is a first-class free-text option (PAP-419)", () => {
+ expect(isDegenerateAskUserQuestions(askInteraction([
+ {
+ id: "q1",
+ prompt: "Describe your goal",
+ selectionMode: "single",
+ options: [{ id: "other", label: "I'll describe it", freeText: true }],
+ },
+ ]))).toBe(false);
+ });
+
+ it("is degenerate only when EVERY question is degenerate", () => {
+ // One real question keeps the whole card, even next to a degenerate one
+ // (here: a question with no options and no free-text).
+ expect(isDegenerateAskUserQuestions(askInteraction([
+ { id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
+ {
+ id: "q2",
+ prompt: "Ship it?",
+ selectionMode: "single",
+ options: [
+ { id: "yes", label: "Yes" },
+ { id: "no", label: "No" },
+ ],
+ },
+ ]))).toBe(false);
+ });
+
+ it("ignores non-ask_user_questions interactions", () => {
+ const confirmation = {
+ id: "interaction-confirm",
+ companyId: "company-1",
+ issueId: "issue-1",
+ kind: "request_confirmation",
+ status: "pending",
+ continuationPolicy: "wake_assignee",
+ ...resolverPolicyFields,
+ createdAt: "2026-04-06T12:00:00.000Z",
+ updatedAt: "2026-04-06T12:00:00.000Z",
+ payload: { version: 1 },
+ } as unknown as IssueThreadInteraction;
+ expect(isDegenerateAskUserQuestions(confirmation)).toBe(false);
+ });
+});
+
+describe("isSupersededByNewerSiblingInteraction", () => {
+ function askInteraction(overrides: Partial): AskUserQuestionsInteraction {
+ return {
+ id: "interaction-ask",
+ companyId: "company-1",
+ issueId: "issue-1",
+ kind: "ask_user_questions",
+ status: "expired",
+ continuationPolicy: "wake_assignee",
+ ...resolverPolicyFields,
+ createdAt: "2026-04-06T12:00:00.000Z",
+ updatedAt: "2026-04-06T12:00:00.000Z",
+ payload: {
+ version: 1,
+ questions: [{ id: "q", prompt: "t", selectionMode: "single", options: [{ id: "L", label: "L" }] }],
+ },
+ ...overrides,
+ } as AskUserQuestionsInteraction;
+ }
+
+ it("hides an expired card superseded by a newer sibling question", () => {
+ const interaction = askInteraction({
+ status: "expired",
+ result: {
+ version: 1,
+ answers: [],
+ expirationReason: "superseded_by_newer_interaction",
+ supersededByInteractionId: "interaction-newer",
+ summaryMarkdown: null,
+ },
+ });
+ expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(true);
+ expect(shouldHideInteractionCard(interaction)).toBe(true);
+ });
+
+ it("never hides a still-pending card (would strand the assignee)", () => {
+ // Guards the PAP-424 / 00b136f45 invariant: a pending question must always
+ // render even if the result somehow already names a superseding sibling.
+ const interaction = askInteraction({
+ status: "pending",
+ result: {
+ version: 1,
+ answers: [],
+ expirationReason: "superseded_by_newer_interaction",
+ supersededByInteractionId: "interaction-newer",
+ summaryMarkdown: null,
+ },
+ });
+ expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(false);
+ expect(shouldHideInteractionCard(interaction)).toBe(false);
+ });
+
+ it("keeps the stale notice for a comment-superseded card (does not hide it)", () => {
+ const interaction = askInteraction({
+ status: "expired",
+ result: {
+ version: 1,
+ answers: [],
+ expirationReason: "superseded_by_comment",
+ commentId: "11111111-1111-1111-1111-111111111111",
+ summaryMarkdown: null,
+ },
+ });
+ expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(false);
+ expect(shouldHideInteractionCard(interaction)).toBe(false);
+ });
+
+ it("still hides a degenerate card through the combined predicate", () => {
+ const degenerate = askInteraction({
+ status: "pending",
+ result: null,
+ payload: { version: 1, questions: [] },
+ });
+ expect(isSupersededByNewerSiblingInteraction(degenerate)).toBe(false);
+ expect(shouldHideInteractionCard(degenerate)).toBe(true);
+ });
+});
diff --git a/ui/src/lib/issue-thread-interactions.ts b/ui/src/lib/issue-thread-interactions.ts
index 1e20e13e279..695b7c3aac0 100644
--- a/ui/src/lib/issue-thread-interactions.ts
+++ b/ui/src/lib/issue-thread-interactions.ts
@@ -304,3 +304,81 @@ export function getQuestionAnswerLabels(args: {
if (otherText) labels.push(`Other: ${otherText}`);
return labels;
}
+
+/**
+ * A single `ask_user_questions` question is degenerate when it offers *no way at
+ * all* to answer — hiding it therefore strands nothing the user could have
+ * resolved. Structural only, no semantic guessing about the wording:
+ *
+ * - its `prompt` is empty / whitespace-only, OR
+ * - it presents nothing to respond to: no first-class free-text option (the
+ * PAP-419 `freeText` flag) AND no selectable fixed option.
+ *
+ * A question with even a single fixed option is answerable (the user selects it
+ * and submits), so it is NOT degenerate and must keep rendering — otherwise a
+ * hidden-but-pending interaction would strand the assignee waiting on a response
+ * that can never arrive. Legitimate shapes all pass: yes/no, multi-select,
+ * free-text, and single-option acknowledgements.
+ */
+function isDegenerateAskUserQuestion(question: AskUserQuestionsQuestion): boolean {
+ if (question.prompt.trim().length === 0) return true;
+ const hasFreeTextOption = question.options.some((option) => option.freeText === true);
+ if (hasFreeTextOption) return false;
+ const selectableOptionCount = question.options.filter(
+ (option) => option.freeText !== true,
+ ).length;
+ return selectableOptionCount === 0;
+}
+
+/**
+ * Structural render guard for `ask_user_questions` cards. A card is degenerate —
+ * safe to never draw because it strands nothing the user could resolve — when it
+ * offers no answerable question: it has zero questions, OR every question is
+ * degenerate (see {@link isDegenerateAskUserQuestion}: blank prompt, or no
+ * option and no free-text). A card with any answerable question — including a
+ * single fixed option — always renders.
+ *
+ * UI-only: the interaction is still created and stored server-side (audit
+ * intact); callers use this purely to decide whether to draw the card. Returns
+ * false for any other interaction kind — the guard is scoped to
+ * `ask_user_questions`.
+ */
+export function isDegenerateAskUserQuestions(
+ interaction: IssueThreadInteraction,
+): boolean {
+ if (interaction.kind !== "ask_user_questions") return false;
+ const questions = interaction.payload.questions;
+ if (questions.length === 0) return true;
+ return questions.every(isDegenerateAskUserQuestion);
+}
+
+/**
+ * A stale sibling `ask_user_questions` that the server auto-expired when its own
+ * creator posted a newer one on the same issue (PAP-437). The replacement card
+ * is already in the thread, so this expired shell adds nothing and is never
+ * drawn. Gated on `status === "expired"` so a still-pending card is never hidden
+ * (PAP-424 / 00b136f45: hiding a pending question would strand the assignee).
+ * Distinct from `superseded_by_comment`, which keeps its stale notice.
+ */
+export function isSupersededByNewerSiblingInteraction(
+ interaction: IssueThreadInteraction,
+): boolean {
+ if (interaction.kind !== "ask_user_questions") return false;
+ if (interaction.status !== "expired") return false;
+ return interaction.result?.expirationReason === "superseded_by_newer_interaction";
+}
+
+/**
+ * Single enforcement point for whether an interaction card should be suppressed
+ * from every thread surface. Routing all render sites through this one predicate
+ * keeps composition backbones and the card in lockstep, so a suppressed card
+ * never leaves an empty slot in one place while another still draws it.
+ */
+export function shouldHideInteractionCard(
+ interaction: IssueThreadInteraction,
+): boolean {
+ return (
+ isDegenerateAskUserQuestions(interaction)
+ || isSupersededByNewerSiblingInteraction(interaction)
+ );
+}
diff --git a/ui/src/lib/onboarding-launch.test.ts b/ui/src/lib/onboarding-launch.test.ts
index bf4141a3f63..1c2f75afc9b 100644
--- a/ui/src/lib/onboarding-launch.test.ts
+++ b/ui/src/lib/onboarding-launch.test.ts
@@ -121,6 +121,7 @@ describe("onboarding launch payloads", () => {
projectId: "project-1",
goalId: "goal-1",
status: "todo",
+ onboardingFirstTask: true,
});
});
@@ -143,6 +144,7 @@ describe("onboarding launch payloads", () => {
assigneeAgentId: "agent-1",
projectId: "project-1",
status: "todo",
+ onboardingFirstTask: true,
});
});
});
diff --git a/ui/src/lib/onboarding-launch.ts b/ui/src/lib/onboarding-launch.ts
index 41487e9d5dc..e59eddb711d 100644
--- a/ui/src/lib/onboarding-launch.ts
+++ b/ui/src/lib/onboarding-launch.ts
@@ -61,5 +61,8 @@ export function buildOnboardingIssuePayload(input: {
projectId: input.projectId,
...(input.goalId ? { goalId: input.goalId } : {}),
status: "todo" as const,
+ // Marks the single onboarding first task so the server seeds an agent
+ // greeting and the task-detail view suppresses the seeded-description bubble.
+ onboardingFirstTask: true,
};
}
diff --git a/ui/src/lib/run-log-chunks.test.ts b/ui/src/lib/run-log-chunks.test.ts
index 551a4f900ba..0aa075b78f0 100644
--- a/ui/src/lib/run-log-chunks.test.ts
+++ b/ui/src/lib/run-log-chunks.test.ts
@@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import type { RunLogChunk } from "../adapters";
import {
+ applyRetentionBudget,
isStructuredStreamingTextDelta,
+ isTrimmedOutputMarkerChunk,
mergeRunLogChunks,
parsePersistedLogContent,
readChunkSeq,
+ TRIMMED_OUTPUT_MARKER_TEXT,
type ChunkMergeRefs,
type IncomingRunLogChunk,
} from "./run-log-chunks";
@@ -127,4 +130,79 @@ describe("mergeRunLogChunks", () => {
expect(result.chunks).toBe(state);
expect(result.changed).toBe(false);
});
+
+ it("retains far more than the old 200-chunk cap under a byte budget", () => {
+ const refs = freshRefs();
+ let state: RunLogChunk[] = [];
+ // 500 one-byte delta chunks — the old count cap would have dropped 300 of
+ // them off the top irreversibly. A generous byte budget keeps them all.
+ for (let seq = 1; seq <= 500; seq += 1) {
+ ({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(seq, "x")], refs, { maxBytes: 10_000 }));
+ }
+ expect(state).toHaveLength(500);
+ expect(state.some(isTrimmedOutputMarkerChunk)).toBe(false);
+ expect(state[0]!.chunk).toBe("x");
+ });
+
+ it("collapses the oldest output behind a single visible marker instead of discarding it", () => {
+ const refs = freshRefs();
+ let state: RunLogChunk[] = [];
+ // Each chunk is 10 units; a 25-unit budget keeps only the newest two.
+ const ten = "0123456789";
+ for (let seq = 1; seq <= 4; seq += 1) {
+ ({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(seq, ten)], refs, {
+ maxBytes: 25,
+ collapseTrimmed: true,
+ }));
+ }
+ // First element is the marker; the two newest real chunks follow.
+ expect(isTrimmedOutputMarkerChunk(state[0]!)).toBe(true);
+ expect(state[0]!.chunk).toBe(TRIMMED_OUTPUT_MARKER_TEXT);
+ expect(state.slice(1).map((c) => c.seq)).toEqual([3, 4]);
+ // Exactly one marker — repeated trims do not stack markers.
+ expect(state.filter(isTrimmedOutputMarkerChunk)).toHaveLength(1);
+ // Trimmed seq floor tracks the removed records so re-delivery is dropped.
+ expect(refs.trimmedSeqFloorByRun.get("r")).toBe(2);
+ });
+});
+
+describe("applyRetentionBudget", () => {
+ const chunk = (seq: number, text: string): RunLogChunk => ({ ts: `t${seq}`, stream: "stdout", chunk: text, seq });
+
+ it("leaves chunks untouched when within budget", () => {
+ const chunks = [chunk(1, "aa"), chunk(2, "bb")];
+ const result = applyRetentionBudget(chunks, { maxBytes: 100, collapseTrimmed: true });
+ expect(result.chunks).toBe(chunks);
+ expect(result.trimmedSeq).toBeNull();
+ });
+
+ it("keeps the newest chunk even when it alone exceeds the byte budget", () => {
+ const chunks = [chunk(1, "0123456789")];
+ const result = applyRetentionBudget(chunks, { maxBytes: 3, collapseTrimmed: true });
+ expect(result.chunks).toEqual(chunks);
+ expect(result.chunks.some(isTrimmedOutputMarkerChunk)).toBe(false);
+ });
+
+ it("discards silently (no marker) when collapseTrimmed is off", () => {
+ const chunks = [chunk(1, "a"), chunk(2, "b"), chunk(3, "c")];
+ const result = applyRetentionBudget(chunks, { maxChunks: 2 });
+ expect(result.chunks.map((c) => c.chunk)).toEqual(["b", "c"]);
+ expect(result.chunks.some(isTrimmedOutputMarkerChunk)).toBe(false);
+ expect(result.trimmedSeq).toBe(1);
+ });
+
+ it("does not accumulate markers when trimming an already-collapsed window", () => {
+ const first = applyRetentionBudget(
+ [chunk(1, "aa"), chunk(2, "bb"), chunk(3, "cc")],
+ { maxBytes: 3, collapseTrimmed: true },
+ );
+ expect(first.chunks.filter(isTrimmedOutputMarkerChunk)).toHaveLength(1);
+ // Feed the marker-prefixed result back in with more content over budget.
+ const second = applyRetentionBudget(
+ [...first.chunks, chunk(4, "dd")],
+ { maxBytes: 3, collapseTrimmed: true },
+ );
+ expect(second.chunks.filter(isTrimmedOutputMarkerChunk)).toHaveLength(1);
+ expect(second.chunks[0]!.chunk).toBe(TRIMMED_OUTPUT_MARKER_TEXT);
+ });
});
diff --git a/ui/src/lib/run-log-chunks.ts b/ui/src/lib/run-log-chunks.ts
index 03c6fc8d848..9072816c0e1 100644
--- a/ui/src/lib/run-log-chunks.ts
+++ b/ui/src/lib/run-log-chunks.ts
@@ -26,6 +26,114 @@ export interface ChunkMergeRefs {
const SEEN_CHUNK_KEY_CAP = 12000;
+/**
+ * Retention budget for a run's kept chunk window. Task views pass a byte budget
+ * with `collapseTrimmed` so the just-rendered scrollback is retained and, when
+ * the budget is genuinely exceeded, the oldest content collapses behind a
+ * visible marker instead of silently vanishing midstream. Compact consumers
+ * (dashboard tickers, summary draft) keep the historical chunk-count cap by
+ * passing a bare number, which discards silently as before.
+ */
+export interface ChunkRetentionBudget {
+ /** Hard ceiling on retained chunk count. */
+ maxChunks?: number;
+ /** Soft ceiling on retained chunk payload size (UTF-16 code units ≈ bytes). */
+ maxBytes?: number;
+ /**
+ * When true, trimming leaves a single visible "earlier output trimmed" marker
+ * at the head so collapsed content never disappears without a trace. When
+ * false (the default for count-only callers), trimming is silent.
+ */
+ collapseTrimmed?: boolean;
+}
+
+/**
+ * Text of the synthetic system chunk that marks where older output was
+ * collapsed out of the retained window. Rendered as an ordinary system line by
+ * `buildTranscript`, so the affordance is adapter-agnostic.
+ */
+export const TRIMMED_OUTPUT_MARKER_TEXT =
+ "⋯ earlier output trimmed to stay within the live transcript buffer ⋯";
+
+export function isTrimmedOutputMarkerChunk(chunk: RunLogChunk): boolean {
+ return (
+ chunk.stream === "system" &&
+ chunk.chunk === TRIMMED_OUTPUT_MARKER_TEXT &&
+ chunk.seq === undefined
+ );
+}
+
+function makeTrimmedOutputMarkerChunk(ts: string): RunLogChunk {
+ return { ts, stream: "system", chunk: TRIMMED_OUTPUT_MARKER_TEXT };
+}
+
+function chunkRetainedSize(chunk: RunLogChunk): number {
+ return chunk.chunk.length;
+}
+
+function normalizeRetentionBudget(budget: number | ChunkRetentionBudget): ChunkRetentionBudget {
+ return typeof budget === "number" ? { maxChunks: budget } : budget;
+}
+
+/**
+ * Trim a run's retained window to its budget. Byte-budget trimming keeps the
+ * newest chunks and (when `collapseTrimmed`) prepends one marker so the
+ * scrollback shows earlier output was collapsed rather than silently dropped.
+ * Returns the highest sequenced chunk actually removed so callers can raise the
+ * trimmed-seq floor and drop re-delivered older records.
+ */
+export function applyRetentionBudget(
+ chunks: RunLogChunk[],
+ budget: ChunkRetentionBudget,
+): { chunks: RunLogChunk[]; trimmedSeq: number | null } {
+ const { maxChunks, maxBytes, collapseTrimmed } = budget;
+
+ // Peel off any existing marker so it is never counted or duplicated; a single
+ // marker is re-added below if trimming is (still) in effect.
+ const hadMarker = chunks.length > 0 && isTrimmedOutputMarkerChunk(chunks[0]!);
+ const real = hadMarker ? chunks.slice(1) : chunks;
+
+ let removeCount = 0;
+ if (typeof maxChunks === "number" && real.length > maxChunks) {
+ removeCount = real.length - maxChunks;
+ }
+ if (typeof maxBytes === "number") {
+ let totalBytes = 0;
+ for (const chunk of real) totalBytes += chunkRetainedSize(chunk);
+ let byteRemove = 0;
+ // Always keep the newest chunk even if it alone exceeds the byte budget.
+ while (byteRemove < real.length - 1 && totalBytes > maxBytes) {
+ totalBytes -= chunkRetainedSize(real[byteRemove]!);
+ byteRemove += 1;
+ }
+ if (byteRemove > removeCount) removeCount = byteRemove;
+ }
+
+ if (removeCount <= 0) {
+ // Nothing new to trim. Preserve an existing marker so an earlier collapse
+ // keeps its trace; otherwise return the marker-stripped array only if we
+ // actually stripped one (we never had a real trim to justify keeping it).
+ if (hadMarker) return { chunks, trimmedSeq: null };
+ return { chunks: real, trimmedSeq: null };
+ }
+
+ const removed = real.slice(0, removeCount);
+ const kept = real.slice(removeCount);
+
+ let trimmedSeq: number | null = null;
+ for (const item of removed) {
+ if (typeof item.seq === "number" && (trimmedSeq === null || item.seq > trimmedSeq)) {
+ trimmedSeq = item.seq;
+ }
+ }
+
+ if (collapseTrimmed || hadMarker) {
+ const markerTs = kept[0]?.ts ?? removed[removed.length - 1]!.ts;
+ return { chunks: [makeTrimmedOutputMarkerChunk(markerTs), ...kept], trimmedSeq };
+ }
+ return { chunks: kept, trimmedSeq };
+}
+
export function readChunkSeq(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
@@ -96,7 +204,7 @@ export function mergeRunLogChunks(
prevChunks: RunLogChunk[],
incoming: IncomingRunLogChunk[],
refs: ChunkMergeRefs,
- maxChunksPerRun: number,
+ budget: number | ChunkRetentionBudget,
): { chunks: RunLogChunk[]; changed: boolean } {
if (incoming.length === 0) return { chunks: prevChunks, changed: false };
@@ -144,14 +252,12 @@ export function mergeRunLogChunks(
if (refs.seenChunkKeys.size > SEEN_CHUNK_KEY_CAP) {
refs.seenChunkKeys.clear();
}
- if (existing.length > maxChunksPerRun) {
- const trimmed = existing.splice(0, existing.length - maxChunksPerRun);
- let seqFloor = refs.trimmedSeqFloorByRun.get(runId) ?? 0;
- for (const item of trimmed) {
- if (typeof item.seq === "number" && item.seq > seqFloor) seqFloor = item.seq;
- }
- if (seqFloor > 0) refs.trimmedSeqFloorByRun.set(runId, seqFloor);
+
+ const { chunks: retained, trimmedSeq } = applyRetentionBudget(existing, normalizeRetentionBudget(budget));
+ if (trimmedSeq !== null) {
+ const seqFloor = refs.trimmedSeqFloorByRun.get(runId) ?? 0;
+ if (trimmedSeq > seqFloor) refs.trimmedSeqFloorByRun.set(runId, trimmedSeq);
}
- return { chunks: existing, changed: true };
+ return { chunks: retained, changed: true };
}
diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx
index 04dc76705cd..af031dc56ee 100644
--- a/ui/src/pages/InstanceExperimentalSettings.test.tsx
+++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx
@@ -49,8 +49,8 @@ const STREAMLINED_TOGGLE_SELECTOR =
'button[aria-label="Toggle streamlined left navigation experimental setting"]';
const TASK_WATCHDOGS_TOGGLE_SELECTOR =
'button[aria-label="Toggle task watchdogs experimental setting"]';
-const TASK_CHAT_REDESIGN_TOGGLE_SELECTOR =
- 'button[aria-label="Toggle chat-style tasks experimental setting"]';
+const CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR =
+ 'button[aria-label="Toggle classic task interface experimental setting"]';
const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR =
'button[aria-label="Toggle goals sidebar link experimental setting"]';
const DECISIONS_TOGGLE_SELECTOR =
@@ -78,7 +78,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
- enableTaskChatRedesign: false,
+ enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
@@ -294,18 +294,20 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
});
});
- it("renders and patches the Chat-Style Tasks experimental toggle on and off", async () => {
+ it("renders and patches the Classic Task Interface experimental toggle on and off", async () => {
await renderPage();
- expect(container.textContent).toContain("Chat-Style Tasks");
+ expect(container.textContent).toContain("Classic Task Interface");
expect(container.textContent).toContain(
- "Reimagines the task detail page as a live conversation with your agents",
+ "Restores the previous task detail page",
);
expect(container.textContent).toContain(
- "Turning this off instantly restores the classic task page. No task data is affected.",
+ "Switching takes effect immediately. No task data is affected.",
);
- const toggle = container.querySelector(TASK_CHAT_REDESIGN_TOGGLE_SELECTOR);
+ const toggle = container.querySelector(
+ CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR,
+ );
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
@@ -314,7 +316,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
- enableTaskChatRedesign: true,
+ enableClassicTaskInterface: true,
});
expect(toggle?.getAttribute("aria-checked")).toBe("true");
@@ -326,7 +328,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await renderPage();
const enabledToggle = container.querySelector(
- TASK_CHAT_REDESIGN_TOGGLE_SELECTOR,
+ CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR,
);
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
@@ -336,7 +338,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({
- enableTaskChatRedesign: false,
+ enableClassicTaskInterface: false,
});
});
diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx
index 1be545941ca..ca31ff0b807 100644
--- a/ui/src/pages/InstanceExperimentalSettings.tsx
+++ b/ui/src/pages/InstanceExperimentalSettings.tsx
@@ -360,7 +360,7 @@ export function InstanceExperimentalSettings() {
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
- const enableTaskChatRedesign = experimentalQuery.data?.enableTaskChatRedesign === true;
+ const enableClassicTaskInterface = experimentalQuery.data?.enableClassicTaskInterface === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
const enableExperimentalFileViewer =
@@ -601,14 +601,14 @@ export function InstanceExperimentalSettings() {
/>
toggleMutation.mutate({ enableTaskChatRedesign: checked })}
+ title="Classic Task Interface"
+ description="Restores the previous task detail page: the page-level header with inline description editing, the plain comment thread, and the fixed Properties sidebar. Chat-only features — streaming activity folding, inline plan and question cards, the three-mode composer — are unavailable in the classic view."
+ footnote="Switching takes effect immediately. No task data is affected."
+ checked={enableClassicTaskInterface}
+ onCheckedChange={(checked) => toggleMutation.mutate({ enableClassicTaskInterface: checked })}
disabled={toggleMutation.isPending}
- managed={managedKeys.enableTaskChatRedesign}
- ariaLabel="Toggle chat-style tasks experimental setting"
+ managed={managedKeys.enableClassicTaskInterface}
+ ariaLabel="Toggle classic task interface experimental setting"
/>
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index 8ef40448e7e..6758db200fd 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, Issue, IssueAttachment, IssueComment, IssueTreeControlPreview, IssueTreeHold, IssueWorkProduct } from "@paperclipai/shared";
+import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
import { NavigationType } from "react-router-dom";
import { flushSync } from "react-dom";
@@ -39,6 +40,7 @@ const mockIssuesApi = vi.hoisted(() => ({
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
upsertDocument: vi.fn(),
+ getDocument: vi.fn(),
}));
const mockActivityApi = vi.hoisted(() => ({
@@ -290,11 +292,47 @@ vi.mock("../components/IssueChatThread", () => ({
},
}));
-// The redesign thread pulls in the MarkdownEditor composer, whose @mdxeditor
-// dependency cannot load under jsdom's CSSOM. These tests exercise the legacy
-// (flag-off) path, so an inert stub keeps the suite unit-scoped.
+// The task chat thread pulls in the MarkdownEditor composer, whose @mdxeditor
+// dependency cannot load under jsdom's CSSOM. The stub keeps the suite
+// unit-scoped but still renders the threadHeader JSX (the issue header row
+// lives inside the thread) so header controls stay testable, records its props
+// on the shared thread-render spy, and exposes the same run-control buttons as
+// the IssueChatThread stub above.
vi.mock("../components/TaskChatThread", () => ({
- TaskChatThread: () => Task chat thread
,
+ TaskChatThread: (props: {
+ threadHeader?: ReactNode;
+ onStopRun?: (runId: string) => Promise;
+ stopRunLabel?: string;
+ runFinalizationActions?: readonly {
+ id: string;
+ label: string;
+ onSelect: (runId: string) => Promise | void;
+ }[];
+ footer?: ReactNode;
+ }) => {
+ mockIssueChatThreadRender(props);
+ return (
+