From 0e9b03832d55307d07e29f979f3680b841ae2f42 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 18 Aug 2026 09:29:31 -0700 Subject: [PATCH 01/39] fix(server): skip host provisionCommand for sandbox-driver environments (#11626) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The run orchestrator prepares an execution environment before an agent starts > - A sandbox driver creates a remote folder before the adapter uploads repository content > - The orchestrator ran the host `provisionCommand` in that empty folder > - The command failed with exit 127 before the adapter could run its `stage.sync` step > - This pull request skips host provisioning for sandbox drivers and keeps the existing local and SSH behavior > - The benefit is that sandbox runs reach the adapter sync step without an empty-folder setup failure ## Linked Issues or Issue Description **What happened?** A sandbox environment ran the host `provisionCommand` before the adapter uploaded repository content. The command ran in an empty remote folder and failed with exit 127. **Expected behavior** The orchestrator should skip host provisioning for a sandbox driver. The adapter should upload the provisioned tree during its `stage.sync` step. **Steps to reproduce** 1. Configure an environment with the `sandbox` driver and a host `provisionCommand`. 2. Start a run that uses this environment. 3. Observe that the command runs in the empty sandbox folder and the run fails with `setup_failed`. **Paperclip version or commit** Reproduced on the current `master` commit before this change. **Deployment mode** Built from source with a sandbox environment. **Installation method** Built from source with pnpm. **Agent adapter(s) involved** Not adapter-specific (core bug). The sandbox adapter syncs the tree after environment setup. **Database mode** Not database-related. **Additional context** Related context: [#11091](https://github.com/paperclipai/paperclip/pull/11091) changes provision behavior for reused workspaces. This pull request covers the separate sandbox ordering failure. ## What Changed - Skip the orchestrator provision step when `environment.driver` is `sandbox`. - Keep the existing skip for `local` and the provision step for `ssh`. - Log one info message when a sandbox skip drops a present command. - Keep the existing `plugin` path because it has no `stage.sync` step and runs against the host filesystem. - Add tests for sandbox, local, SSH, plugin, logging, and provision failures. ## Verification - Run `./node_modules/.bin/vitest run server/src/__tests__/environment-run-orchestrator.test.ts`. - Confirm that the test run passes all 10 tests. - Confirm that CI checks pass on this pull request. ## Risks - Low risk. The change affects only the provision gate for sandbox drivers. - SSH and local behavior stays unchanged. - The plugin driver stays on its current path. - The new log line makes a sandbox skip visible to operators. ## Model Used OpenAI, GPT-5, exact runtime model `gpt-5`, with tool use and code review support. The implementation author used this model to inspect code, edit source and tests, and run the targeted test suite. ## 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 (no exact duplicate found; related PR #11091 reviewed) - [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 documentation change applies to this internal gate correction) - [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 --- .../environment-run-orchestrator.test.ts | 150 ++++++++++++++++-- .../services/environment-run-orchestrator.ts | 25 ++- 2 files changed, 164 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 41c2640c1d8..01bb0442a9a 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -10,6 +10,7 @@ const mockBuildWorkspaceRealizationRequest = vi.hoisted(() => vi.fn()); const mockUpdateLeaseMetadata = vi.hoisted(() => vi.fn()); const mockUpdateExecutionWorkspace = vi.hoisted(() => vi.fn()); const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockLoggerInfo = vi.hoisted(() => vi.fn()); vi.mock("../services/environment-execution-target.js", () => ({ resolveEnvironmentExecutionTarget: mockResolveEnvironmentExecutionTarget, @@ -44,6 +45,15 @@ vi.mock("../services/activity-log.js", () => ({ logActivity: mockLogActivity, })); +vi.mock("../middleware/logger.js", () => ({ + logger: { + info: mockLoggerInfo, + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + // --------------------------------------------------------------------------- // Imports after mocks // --------------------------------------------------------------------------- @@ -407,6 +417,84 @@ describe("environmentRunOrchestrator — realizeForRun", () => { }); it("runs a remote provision command after workspace realization when configured", async () => { + mockBuildWorkspaceRealizationRequest.mockReturnValue({ + version: 1, + adapterType: "claude_local", + companyId: "company-1", + environmentId: "env-1", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: "run-1", + requestedMode: null, + source: { + kind: "project_primary", + localPath: "/workspace/project", + projectId: null, + projectWorkspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, + }, + runtimeOverlay: { + provisionCommand: "npm install -g @anthropic-ai/claude-code", + }, + }); + mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + kind: "remote", + transport: "ssh", + remoteCwd: "/remote/workspace", + environmentId: "env-1", + leaseId: "lease-1", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/remote/workspace", + remoteWorkspacePath: "/remote/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }); + + const runtime = makeMockRuntime({ + realizeWorkspace: vi.fn().mockResolvedValue({ + cwd: "/remote/workspace", + metadata: { + workspaceRealization: { + version: 1, + transport: "ssh", + remote: { path: "/remote/workspace" }, + }, + }, + }), + }); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + + await orchestrator.realizeForRun(makeRealizeInput({ + environment: makeEnvironment("ssh"), + })); + + // The `ssh` driver runs the command on the remote host that shares the + // workspace path, so the configured provision command still runs there. + expect(runtime.execute).toHaveBeenCalledOnce(); + expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({ + environment: expect.objectContaining({ driver: "ssh" }), + lease: expect.objectContaining({ id: "lease-1" }), + command: "bash", + args: ["-lc", "npm install -g @anthropic-ai/claude-code"], + cwd: "/remote/workspace", + env: { + SHELL: "/bin/bash", + }, + })); + // The sandbox skip log is specific to the sandbox driver; ssh stays quiet. + expect(mockLoggerInfo).not.toHaveBeenCalled(); + }); + + it("skips the host provision command for a sandbox environment and logs the skip", async () => { mockBuildWorkspaceRealizationRequest.mockReturnValue({ version: 1, adapterType: "claude_local", @@ -458,17 +546,59 @@ describe("environmentRunOrchestrator — realizeForRun", () => { environment: makeEnvironment("sandbox"), })); - expect(runtime.execute).toHaveBeenCalledOnce(); - expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({ - environment: expect.objectContaining({ driver: "sandbox" }), - lease: expect.objectContaining({ id: "lease-1" }), - command: "bash", - args: ["-lc", "npm install -g @anthropic-ai/claude-code"], - cwd: "/remote/workspace", - env: { - SHELL: "/bin/bash", + // The sandbox receives the provisioned tree through the adapter stage.sync + // step, so the orchestrator must not run the host command in the sandbox. + expect(runtime.execute).not.toHaveBeenCalled(); + // The skip is observable: exactly one log line records it with the driver. + expect(mockLoggerInfo).toHaveBeenCalledOnce(); + expect(mockLoggerInfo).toHaveBeenCalledWith( + expect.objectContaining({ driver: "sandbox", environmentId: "env-1" }), + expect.stringContaining("Skip host provisionCommand"), + ); + }); + + it("does not rerun the provision command during local environment realization", async () => { + mockBuildWorkspaceRealizationRequest.mockReturnValue({ + version: 1, + adapterType: "claude_local", + companyId: "company-1", + environmentId: "env-1", + executionWorkspaceId: null, + issueId: null, + heartbeatRunId: "run-1", + requestedMode: null, + source: { + kind: "project_primary", + localPath: "/workspace/project", + projectId: null, + projectWorkspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "project_primary", + branchName: null, + worktreePath: null, }, + runtimeOverlay: { + provisionCommand: "npm install -g @anthropic-ai/claude-code", + }, + }); + mockResolveEnvironmentExecutionTarget.mockResolvedValue({ + kind: "local", + environmentId: "env-1", + leaseId: "lease-1", + }); + + const runtime = makeMockRuntime(); + const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); + + await orchestrator.realizeForRun(makeRealizeInput({ + environment: makeEnvironment("local"), })); + + // Local workspace provisioning already ran the command before realizeForRun. + expect(runtime.execute).not.toHaveBeenCalled(); + // The sandbox skip log is specific to the sandbox driver; local stays quiet. + expect(mockLoggerInfo).not.toHaveBeenCalled(); }); it("runs project-level provision commands for ssh environments", async () => { @@ -588,7 +718,7 @@ describe("environmentRunOrchestrator — realizeForRun", () => { const orchestrator = environmentRunOrchestrator(mockDb, { environmentRuntime: runtime }); await expect(orchestrator.realizeForRun(makeRealizeInput({ - environment: makeEnvironment("sandbox"), + environment: makeEnvironment("ssh"), }))).rejects.toSatisfy( (err: unknown) => err instanceof EnvironmentRunError && diff --git a/server/src/services/environment-run-orchestrator.ts b/server/src/services/environment-run-orchestrator.ts index 975358ec82e..c907d4c177e 100644 --- a/server/src/services/environment-run-orchestrator.ts +++ b/server/src/services/environment-run-orchestrator.ts @@ -45,6 +45,7 @@ import { import { buildWorkspaceRealizationRequest } from "./workspace-realization.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; import { logActivity } from "./activity-log.js"; +import { logger } from "../middleware/logger.js"; import { parseObject } from "../adapters/utils.js"; import type { RealizedExecutionWorkspace } from "./workspace-runtime.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; @@ -420,7 +421,29 @@ export function environmentRunOrchestrator( (typeof lease.metadata?.remoteCwd === "string" && lease.metadata.remoteCwd.trim().length > 0 ? lease.metadata.remoteCwd.trim() : executionWorkspace.cwd); - if (provisionCommand && environment.driver !== "local") { + // The host `provisionCommand` runs on the host worktree during the + // `workspace_provision` step, before the run reaches the environment. + // A `sandbox`-driver environment does not receive the repo tree here. The + // sandbox driver `realizeWorkspace` step only creates the remote folder. + // The adapter uploads the provisioned tree later, in its `stage.sync` step. + // So the host command must not run inside the still-empty sandbox; it fails + // there (exit 127). Skip the step for `sandbox`, and keep the existing skip + // for `local`. Keep the step for `ssh`, which runs the command on the + // remote host that shares the workspace path. + const driverSkipsHostProvision = + environment.driver === "local" || environment.driver === "sandbox"; + if (provisionCommand && environment.driver === "sandbox") { + logger.info( + { + environmentId: environment.id, + driver: environment.driver, + issueId, + heartbeatRunId, + }, + "Skip host provisionCommand for sandbox-driver environment; the adapter stage.sync step delivers the provisioned tree", + ); + } + if (provisionCommand && !driverSkipsHostProvision) { try { const provisionResult = await environmentRuntime.execute({ environment, From 9ea8143c870afe24fbc9de8ba65be66082fa20ad Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 18 Aug 2026 11:00:54 -0700 Subject: [PATCH 02/39] fix(adapter-utils): give sync-created merge commits a deterministic git identity (#11637) 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 > - Runs execute in transported workspaces; at finalize, the host syncs the sandbox git history back into the local worktree > - When both sides advanced, `integrateImportedGitHead` reconciles them with `git merge-tree` plus `git commit-tree` on the host > - Execution hosts are often containers with no git config and no resolvable hostname, so `commit-tree` fails with "Author identity unknown" > - That one local command failure marks the whole run as failed, even though the run's work succeeded > - This pull request gives sync-created merge commits an explicit, deterministic identity at the call site > - The benefit is that workspace finalize no longer depends on ambient host git configuration ## Linked Issues or Issue Description No existing issue found. I searched issues and PRs for "Author identity unknown", "unrelated histories", and "commit-tree identity". **What happened?** A run finished its work, but workspace finalize failed. The host-side sync ran `git commit-tree -p -p -m "Paperclip remote git sync merge "`. Git exited with `Author identity unknown ... fatal: unable to auto-detect email address (got 'node@.(none)')`. The adapter recorded the whole run as failed, and the host worktree kept the stale head. Any container deployment without a global gitconfig reproduces this; I observed it on a Paperclip Cloud stack. **Expected behavior** Commits that the sync machinery itself creates must not depend on ambient host git configuration. The merge commit is machine-authored, so it should carry a deterministic Paperclip identity. **Steps to reproduce** 1. Run the Paperclip server in a container with no `user.name`/`user.email` git config and a hostname git cannot turn into an email. 2. Let a run's sandbox branch diverge from the host worktree, so both sides advance. 3. Workspace finalize calls `integrateImportedGitHead`. The `git commit-tree` step fails with "Author identity unknown" and the run fails. ## What Changed - `git-workspace-sync.ts`: new exported `GIT_SYNC_COMMIT_IDENTITY_ARGS` (`-c user.name=Paperclip -c user.email=noreply@paperclip.ing`), applied to the `commit-tree` call in `integrateImportedGitHead`. - `ssh.ts`: the SSH-sync copy of `integrateImportedGitHead` applies the same identity args to its `commit-tree` call. - New regression test: builds divergent histories in a repo with no configured identity and asserts the sync merge commit is created with the deterministic identity, correct parents, and merged tree. ## Verification - `pnpm vitest run packages/adapter-utils/src/git-workspace-sync.test.ts` — 18/18 pass. - `pnpm --filter @paperclipai/adapter-utils typecheck` — clean. - Negative proof: with the source fix stashed, the new test fails on the identity assertion. - Full `pnpm vitest run packages/adapter-utils`: every file passes except `local-process-sandbox.test.ts`, which fails identically on an untouched `master` checkout on macOS (bubblewrap-dependent, pre-existing, unrelated). ## Risks Low risk. The change only adds `-c` identity flags to two machine-generated commit invocations. `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables still take precedence over `-c` when an operator sets them, so existing deployments that configure an identity keep their behavior. ## Model Used - Claude Fable 5 (`claude-fable-5`), extended thinking, via Claude Code CLI (tool use for code exploration, test runs, and 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 (no doc surface describes this internal sync path) - [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 --- .../src/git-workspace-sync.test.ts | 55 +++++++++++++++++++ .../adapter-utils/src/git-workspace-sync.ts | 18 ++++++ packages/adapter-utils/src/ssh.ts | 3 +- 3 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 1a233e4c5ac..5bc79af33ad 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -11,6 +11,7 @@ import { createRemoteGitExportRef, deleteLocalGitRef, fetchGitBundleIntoLocalRef, + integrateImportedGitHead, isMissingGitPrerequisiteError, readGitWorkspaceSnapshot, runLocalGit, @@ -422,6 +423,60 @@ describe("git workspace sync", () => { await deleteLocalGitRef({ localDir: host, ref: importedRef }); } }); + + it("creates the concurrent-history merge commit with a deterministic identity", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-merge-identity-")); + cleanupDirs.push(rootDir); + // No repo-local user.name/user.email on purpose: execution hosts are + // containers without git config, where commit-tree cannot auto-detect an + // identity. Setup commits pass their identity inline so only the merge + // commit under test depends on the sync-supplied identity. + const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"]; + const repo = path.join(rootDir, "repo"); + await mkdir(repo, { recursive: true }); + await git(repo, ["init"]); + await git(repo, ["checkout", "-b", "main"]); + await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8"); + await git(repo, ["add", "tracked.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "base"]); + const baseHead = await git(repo, ["rev-parse", "HEAD"]); + + await writeFile(path.join(repo, "local.txt"), "local\n", "utf8"); + await git(repo, ["add", "local.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "local advance"]); + const currentHead = await git(repo, ["rev-parse", "HEAD"]); + + await git(repo, ["checkout", "-b", "imported", baseHead]); + await writeFile(path.join(repo, "imported.txt"), "imported\n", "utf8"); + await git(repo, ["add", "imported.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "sandbox change"]); + const importedHead = await git(repo, ["rev-parse", "HEAD"]); + await git(repo, ["checkout", "main"]); + + // Ambient identity env vars would override the `-c` flags and make the + // assertion machine-dependent, so clear them for the call under test. + const identityEnvKeys = ["GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL", "GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL", "EMAIL"]; + const savedEnv = new Map(identityEnvKeys.map((key) => [key, process.env[key]])); + for (const key of identityEnvKeys) delete process.env[key]; + try { + await integrateImportedGitHead({ localDir: repo, importedHead }); + } finally { + for (const [key, value] of savedEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + + const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" "); + expect(parents.slice(1)).toEqual([currentHead, importedHead]); + expect(await git(repo, ["log", "-1", "--format=%an|%ae|%cn|%ce"])) + .toBe("Paperclip|noreply@paperclip.ing|Paperclip|noreply@paperclip.ing"); + expect(await git(repo, ["log", "-1", "--format=%s"])) + .toBe(`Paperclip remote git sync merge ${importedHead.slice(0, 12)}`); + const mergedTree = await git(repo, ["ls-tree", "--name-only", "HEAD"]); + expect(mergedTree).toContain("local.txt"); + expect(mergedTree).toContain("imported.txt"); + }); }); describe("sanitizeGitRemoteUrl", () => { diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index fd3525ecc05..eb2973b1163 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -42,6 +42,23 @@ export function setExpensiveWorkspaceGitExecutor(executor: ExpensiveWorkspaceGit export const GIT_ARCHIVE_EXCLUDES = [".git", ".git/*"] as const; +/** + * Identity flags for commits the sync machinery itself creates (the merge + * commits that reconcile concurrent histories). Execution hosts are often + * containers with no git config and no resolvable hostname, so git cannot + * auto-detect an identity there and `commit-tree` hard-fails with "Author + * identity unknown" — which fails the whole run at finalize. Passing the + * identity per invocation keeps every deployment working without host + * configuration; `GIT_AUTHOR_*` / `GIT_COMMITTER_*` environment variables + * still take precedence over `-c` when an operator sets them. + */ +export const GIT_SYNC_COMMIT_IDENTITY_ARGS = [ + "-c", + "user.name=Paperclip", + "-c", + "user.email=noreply@paperclip.ing", +] as const; + function shellQuote(value: string) { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } @@ -476,6 +493,7 @@ export async function integrateImportedGitHead(input: { const mergeCommit = await runLocalGit( input.localDir, [ + ...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", mergedTreeId, "-p", diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index de86cdb678a..5cda8c60eb0 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -6,7 +6,7 @@ import os from "node:os"; import path from "node:path"; import { Transform } from "node:stream"; import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; -import { readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js"; +import { GIT_SYNC_COMMIT_IDENTITY_ARGS, readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js"; import type { RunProcessResult } from "./server-utils.js"; import type { DirectorySnapshot } from "./workspace-restore-merge.js"; import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; @@ -974,6 +974,7 @@ async function integrateImportedGitHead(input: { const mergeCommit = await runLocalGit( input.localDir, [ + ...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", mergedTreeId, "-p", From 393da0f67c063ab42f1c69e987239ef31b9d9cbc Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 18 Aug 2026 11:14:39 -0700 Subject: [PATCH 03/39] fix(adapter-utils): graft unrelated imported histories instead of failing the run (#11638) 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 > - At run finalize, the host imports the sandbox git history and reconciles it with the local worktree in `integrateImportedGitHead` > - Transported workspaces are depth-1 shallow clones, so the boundary commit reads as parentless inside the sandbox > - A `git commit --amend` there rewrites the boundary commit into a root commit, and the re-imported history no longer connects to the host history > - `git merge-tree` has no common base to merge against, so the sync throws "Failed to merge concurrent remote git histories" and the run fails with its work stranded in the sandbox > - This pull request grafts the imported tree onto the current head as a single commit instead of failing > - The benefit is that a history rewrite inside the sandbox can no longer lose a run's work ## Linked Issues or Issue Description No existing issue found. I searched issues and PRs for "unrelated histories", "Failed to merge concurrent", and "shallow". Depends on #11637 (merged; the graft commit reuses its identity constant). This PR is now rebased onto `master`. **What happened?** An agent run amended a commit inside its sandbox workspace to address review feedback. The sandbox clone is depth-1 shallow, so git treated the boundary commit as parentless and the amend produced a root commit. At finalize, the host-side sync failed with `Failed to merge concurrent remote git histories for ` and the run was marked failed. A follow-up run had to repair the branch by hand: fetch the true parent from origin and rebuild the commit with `git commit-tree`. **Expected behavior** The sync must never strand completed work. When the imported history shares no ancestor with the local one, the imported tree should still land on the current head, with the imported message preserved and the graft recorded. **Steps to reproduce** 1. Start a run whose workspace transport uses the shallow clone path (`withShallowGitWorkspaceClone`, depth 1). 2. Inside the sandbox workspace, run `git commit --amend` on the boundary commit. The result is a parentless root commit. 3. Finish the run. The host-side `integrateImportedGitHead` finds no merge base, `merge-tree` fails, and the run fails. ## What Changed - `git-workspace-sync.ts`: new exported `createUnrelatedHistoryGraftCommit` helper. It reads the imported head's tree and message, and creates one commit on top of the current head with the deterministic sync identity and a trailer that records the graft and both shas. - `integrateImportedGitHead` (both the remote-git-sync version and the SSH copy in `ssh.ts`): when `merge-base` reports no common ancestor, graft instead of throwing. The ref update keeps the same compare-and-swap and concurrent-retry semantics as the merge path. - The graft is gated on `git merge-base` exiting with status 1 — the no-ancestor signal. Operational failures (timeout, missing object, repository error) keep the loud merge failure instead of rewriting the tip. - New regression tests: one builds the exact shallow-amend shape (a root commit rebuilt from the base tree) and asserts the graft lands on the current head with the imported tree, subject, and graft trailer; one integrates a well-formed sha the repository does not hold and asserts the integration still throws with the branch tip unchanged. ## Verification - `pnpm vitest run packages/adapter-utils/src/git-workspace-sync.test.ts` — 19/19 pass (includes the new graft test and the merge-base failure-discrimination test). - `pnpm --filter @paperclipai/adapter-utils typecheck` — clean. - Full `pnpm vitest run packages/adapter-utils`: every file passes except `local-process-sandbox.test.ts`, which fails identically on an untouched `master` checkout on macOS (bubblewrap-dependent, pre-existing, unrelated). ## Risks - Behavioral shift: unrelated imported histories previously failed the integration; now they land as a squash-graft. In this degenerate case there is no base to merge against, so the imported tree is taken wholesale and concurrent local-only tree changes are superseded at the tip. The local commits keep their place in the graft's ancestry, and the trailer records both shas, so nothing is unrecoverable. The old behavior lost the imported work instead, which is the worse failure for an autonomous run. - The graft reuses the imported head's commit message, so branch history still reads naturally after a sandbox rewrite. ## Model Used - Claude Fable 5 (`claude-fable-5`), extended thinking, via Claude Code CLI (tool use for code exploration, test runs, and 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 (no doc surface describes this internal sync path) - [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 --- .../src/git-workspace-sync.test.ts | 57 ++++++++++++++ .../adapter-utils/src/git-workspace-sync.ts | 77 ++++++++++++++++++- packages/adapter-utils/src/ssh.ts | 38 ++++++++- 3 files changed, 169 insertions(+), 3 deletions(-) diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 5bc79af33ad..e7f1a6b9a50 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -477,6 +477,63 @@ describe("git workspace sync", () => { expect(mergedTree).toContain("local.txt"); expect(mergedTree).toContain("imported.txt"); }); + + it("grafts an imported head onto the current head when histories share no ancestor", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-graft-")); + cleanupDirs.push(rootDir); + const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"]; + const repo = path.join(rootDir, "repo"); + await mkdir(repo, { recursive: true }); + await git(repo, ["init"]); + await git(repo, ["checkout", "-b", "main"]); + await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8"); + await git(repo, ["add", "tracked.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "base"]); + const baseHead = await git(repo, ["rev-parse", "HEAD"]); + + await writeFile(path.join(repo, "local.txt"), "local\n", "utf8"); + await git(repo, ["add", "local.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "local advance"]); + const currentHead = await git(repo, ["rev-parse", "HEAD"]); + + // The shape a depth-1 shallow clone produces after `git commit --amend`: + // a parentless root commit that shares no ancestor with the host history. + const importedTree = await git(repo, ["rev-parse", `${baseHead}^{tree}`]); + const importedHead = await git(repo, [...setupIdentity, "commit-tree", importedTree, "-m", "sandbox rewrite"]); + + await integrateImportedGitHead({ localDir: repo, importedHead }); + + const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" "); + expect(parents.slice(1)).toEqual([currentHead]); + // The imported tree is taken wholesale: no base exists to merge against. + expect(await git(repo, ["rev-parse", "HEAD^{tree}"])).toBe(importedTree); + expect(await git(repo, ["log", "-1", "--format=%s"])).toBe("sandbox rewrite"); + const body = await git(repo, ["log", "-1", "--format=%B"]); + expect(body).toContain(`Paperclip remote git sync graft ${importedHead.slice(0, 12)}`); + expect(body).toContain("shares no ancestor"); + }); + + it("does not graft when merge-base fails for a reason other than missing ancestry", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-graft-")); + cleanupDirs.push(rootDir); + const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"]; + const repo = path.join(rootDir, "repo"); + await mkdir(repo, { recursive: true }); + await git(repo, ["init"]); + await git(repo, ["checkout", "-b", "main"]); + await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8"); + await git(repo, ["add", "tracked.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "base"]); + const currentHead = await git(repo, ["rev-parse", "HEAD"]); + + // A well-formed sha the repository does not hold: merge-base fails with an + // object error (exit 128), not the no-ancestor signal (exit 1). The graft + // must not fire, and the integration keeps its loud failure. + const missingHead = "0123456789abcdef0123456789abcdef01234567"; + await expect(integrateImportedGitHead({ localDir: repo, importedHead: missingHead })) + .rejects.toThrow(/Failed to merge concurrent remote git histories/); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(currentHead); + }); }); describe("sanitizeGitRemoteUrl", () => { diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index eb2973b1163..6cae5e47247 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -433,6 +433,51 @@ export function buildRemoteGitDeltaBundleScript(input: { ].filter(Boolean).join("\n"); } +/** + * Preserve imported work whose history does not connect to the local one. + * + * The dominant real-world cause is a history rewrite inside a transported + * workspace: transported clones are depth-1 shallow, so the boundary commit + * reads as parentless there and `git commit --amend` rewrites it into a root + * commit that shares no ancestor with the host history. A tree merge is + * impossible without a common ancestor, and failing the integration would + * discard the run's work. Instead, squash-graft the imported tree onto the + * current head as a single commit that reuses the imported head's message, + * with a trailer recording the graft. Concurrent local-only commits keep + * their place in history as the graft's ancestry; the imported tree is taken + * wholesale because no base exists to merge against. The caller advances the + * branch ref to the returned commit. + */ +export async function createUnrelatedHistoryGraftCommit(input: { + localDir: string; + currentHead: string; + importedHead: string; + syncLabel: string; +}): Promise { + const importedTree = (await runLocalGit(input.localDir, ["rev-parse", `${input.importedHead}^{tree}`], { + timeout: 10_000, + maxBuffer: 16 * 1024, + })).stdout.trim(); + const importedMessage = (await runLocalGit(input.localDir, ["log", "-1", "--format=%B", input.importedHead], { + timeout: 10_000, + maxBuffer: 256 * 1024, + })).stdout; + const message = [ + importedMessage.trim(), + "", + `(${input.syncLabel} graft ${input.importedHead.slice(0, 12)}: imported history shares no ancestor with ${input.currentHead.slice(0, 12)})`, + ].join("\n"); + const graftCommit = await runLocalGit( + input.localDir, + [...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", importedTree, "-p", input.currentHead, "-m", message], + { + timeout: 60_000, + maxBuffer: 64 * 1024, + }, + ); + return graftCommit.stdout.trim(); +} + export async function integrateImportedGitHead(input: { localDir: string; importedHead: string; @@ -450,10 +495,18 @@ export async function integrateImportedGitHead(input: { if (!currentHead || currentHead === input.importedHead) return; const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; + // `git merge-base` exits 1 when the commits share no ancestor — the only + // outcome that authorizes the graft fallback below. Every other failure + // (timeout, missing object, repository error) must keep failing the + // integration instead of silently rewriting the tip. + let noCommonAncestor = false; const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], { timeout: 10_000, maxBuffer: 16 * 1024, - }).catch(() => null); + }).catch((error: unknown) => { + noCommonAncestor = (error as { code?: unknown } | null)?.code === 1; + return null; + }); const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; if (mergeBaseHead === input.importedHead) { @@ -473,6 +526,28 @@ export async function integrateImportedGitHead(input: { } } + if (noCommonAncestor) { + // No common ancestor — merging is impossible and failing here would + // discard the imported work. Graft it onto the current head instead; + // see createUnrelatedHistoryGraftCommit. + const graftCommit = await createUnrelatedHistoryGraftCommit({ + localDir: input.localDir, + currentHead, + importedHead: input.importedHead, + syncLabel: "Paperclip remote git sync", + }); + try { + await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return; + } catch (error) { + if (isConcurrentRefUpdateError(error) && attempt < 4) continue; + throw error; + } + } + let mergedTree; try { mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], { diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 5cda8c60eb0..301d85cf585 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -6,7 +6,11 @@ import os from "node:os"; import path from "node:path"; import { Transform } from "node:stream"; import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; -import { GIT_SYNC_COMMIT_IDENTITY_ARGS, readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js"; +import { + createUnrelatedHistoryGraftCommit, + GIT_SYNC_COMMIT_IDENTITY_ARGS, + readSanitizedOriginRemoteUrl, +} from "./git-workspace-sync.js"; import type { RunProcessResult } from "./server-utils.js"; import type { DirectorySnapshot } from "./workspace-restore-merge.js"; import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; @@ -931,10 +935,18 @@ async function integrateImportedGitHead(input: { if (!currentHead || currentHead === input.importedHead) return; const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; + // `git merge-base` exits 1 when the commits share no ancestor — the only + // outcome that authorizes the graft fallback below. Every other failure + // (timeout, missing object, repository error) must keep failing the + // integration instead of silently rewriting the tip. + let noCommonAncestor = false; const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], { timeout: 10_000, maxBuffer: 16 * 1024, - }).catch(() => null); + }).catch((error: unknown) => { + noCommonAncestor = (error as { code?: unknown } | null)?.code === 1; + return null; + }); const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; if (mergeBaseHead === input.importedHead) { @@ -954,6 +966,28 @@ async function integrateImportedGitHead(input: { } } + if (noCommonAncestor) { + // No common ancestor — merging is impossible and failing here would + // discard the imported work. Graft it onto the current head instead; + // see createUnrelatedHistoryGraftCommit. + const graftCommit = await createUnrelatedHistoryGraftCommit({ + localDir: input.localDir, + currentHead, + importedHead: input.importedHead, + syncLabel: "Paperclip SSH sync", + }); + try { + await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return; + } catch (error) { + if (isConcurrentRefUpdateError(error) && attempt < 4) continue; + throw error; + } + } + let mergedTree; try { mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], { From fe803bedf163d187a0885a3f3721e707f06b3b49 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 18 Aug 2026 11:23:27 -0700 Subject: [PATCH 04/39] feat(server): add a configurable cooldown to the terminal workspace reaper (#11642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is an open source app that manages AI agents for work. > - The server manages execution workspaces and their worktrees. > - The terminal workspace reaper removes a workspace when its issue tree reaches a terminal state. > - Immediate removal prevents a person from reopening recently completed work. > - This pull request adds a configurable cooldown before the reaper archives the workspace. > - The cooldown keeps recent work available and keeps immediate cleanup available with value `0`. ## Linked Issues or Issue Description Refs: #7790 **Problem** The reaper archives an execution workspace and deletes its worktree as soon as the issue tree becomes terminal. A person cannot reopen recent work without extra effort. **Expected behavior** The reaper should keep a recently completed workspace during a configurable cooldown window. It should archive older work and support immediate cleanup when the value is `0`. **Proposed solution** Read the cooldown from `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS`. Use a seven-day default. Use the latest terminal timestamp in the source issue tree as the cooldown anchor. ## What Changed - Add `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS` with a seven-day default. - Treat `0` as no cooldown and use the default for negative or non-numeric values. - Use the latest `completedAt` or `cancelledAt` value in the source issue tree. - Use `updatedAt` when a terminal timestamp is null. - Skip candidates inside the cooldown and report them in `skippedCooldown`. - Recheck the cutoff during the guarded archive operation. - Document the environment variable and add focused tests. ## Verification - Run `npx vitest run server/src/__tests__/execution-workspaces-service.test.ts`. - Confirm that the test run passes 66 tests. - Confirm that the tests cover a recent tree, an old tree, value `0`, and a null terminal timestamp. - Confirm that the changed files pass `tsc --noEmit`. ## Risks The default changes terminal workspace cleanup from immediate removal to a seven-day delay. A value of `0` preserves immediate cleanup. The guarded archive check limits race risk during concurrent lifecycle 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. ## Model Used OpenAI Codex, GPT-5, tool use and code execution. This model assisted with the implementation review and PR preparation. ## 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 Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- doc/DEVELOPING.md | 6 + .../execution-workspaces-service.test.ts | 109 ++++++++++++++++++ .../workspace-reaper-cooldown-config.test.ts | 54 +++++++++ server/src/config.ts | 17 +++ server/src/index.ts | 7 +- server/src/services/execution-workspaces.ts | 92 +++++++++++++++ 6 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/workspace-reaper-cooldown-config.test.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index dd287b1ee5b..b2bf7cc7d4e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -834,6 +834,12 @@ Environment overrides: stale-backup warning threshold - `PAPERCLIP_DB_BACKUP_ALERT_FILE=/path/to/failure-marker` lets external cron wrappers surface the last failed backup in `/api/health` +- `PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS=` sets how long the + terminal-workspace reaper waits after an issue tree becomes terminal before it + archives the execution workspace and deletes the worktree. A person can reopen + the work inside this window. The default is `7`. A value of `0` disables the + cooldown and restores immediate reaping. A negative or non-numeric value falls + back to the default. Without `PAPERCLIP_DB_BACKUP_ALERT_FILE`, health checks look for `db-backup-to-s3.failure` in the backup directory, beside the backup directory, diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts index 6a5ee601609..9f3693e1b92 100644 --- a/server/src/__tests__/execution-workspaces-service.test.ts +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -259,6 +259,10 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { pullRequestDetailsByKey.get(`${companyId}:${reference.number}`) ?? { state: "unknown", headRef: null, headSha: null } ), + // Disable the reaper cooldown for the delivery, terminal, race, and + // cleanup tests. They assert immediate reaping. The cooldown gets its own + // tests further down. + workspaceReaperCooldownDays: 0, }); }, 20_000); @@ -687,6 +691,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { // workspace first. const service = executionWorkspaceService(db, { resolvePullRequestDetails: async () => ({ state: "unknown", headRef: null, headSha: null }), + workspaceReaperCooldownDays: 0, }); const firstSweep = await service.sweepTerminalWorkspaces(1); @@ -727,6 +732,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const service = executionWorkspaceService(db, { resolvePullRequestDetails: async () => ({ state: "unknown", headRef: null, headSha: null }), now: () => new Date(clockMs), + workspaceReaperCooldownDays: 0, }); // An eligible ancestry workspace with an old updatedAt. Its source issue @@ -829,6 +835,105 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { expect(finalState?.status).toBe("archived"); }, 30_000); + describe("reaper cooldown", () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const nowMs = Date.UTC(2026, 5, 1); + + function cooldownService(cooldownDays: number) { + return executionWorkspaceService(db, { + resolvePullRequestDetails: async (companyId, reference) => + pullRequestDetailsByKey.get(`${companyId}:${reference.number}`) + ?? { state: "unknown", headRef: null, headSha: null }, + now: () => new Date(nowMs), + workspaceReaperCooldownDays: cooldownDays, + }); + } + + async function statusOf(executionWorkspaceId: string) { + const [row] = await db + .select({ status: executionWorkspaces.status }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, executionWorkspaceId)); + return row?.status ?? null; + } + + it("skips a terminal tree that is younger than the cooldown", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + // Keep the workspace inside the sweep boundary that the fixed clock sets. + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + // The issue became terminal one day ago. A cooldown of seven days is not + // over yet. + await db + .update(issues) + .set({ completedAt: new Date(nowMs - DAY_MS) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(7).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 0, skippedCooldown: 1 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("active"); + }, 20_000); + + it("archives a terminal tree that is older than the cooldown", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + // The issue became terminal ten days ago. The seven-day cooldown is over. + await db + .update(issues) + .set({ completedAt: new Date(nowMs - 10 * DAY_MS) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(7).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 1, skippedCooldown: 0 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("archived"); + }, 20_000); + + it("archives immediately when the cooldown is zero", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + // The issue became terminal now. A cooldown of zero disables the wait. + await db + .update(issues) + .set({ completedAt: new Date(nowMs) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(0).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 1, skippedCooldown: 0 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("archived"); + }, 20_000); + + it("falls back to updatedAt when completedAt is null and gates the archive", async () => { + const seeded = await seedTerminalWorkspace({ mergedPr: true }); + // The done issue has no completedAt. The anchor falls back to updatedAt. + // Set updatedAt two days ago, inside the seven-day cooldown, so the sweep + // must skip. + await db + .update(executionWorkspaces) + .set({ updatedAt: new Date(nowMs - 2 * DAY_MS) }) + .where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + await db + .update(issues) + .set({ completedAt: null, updatedAt: new Date(nowMs - 2 * DAY_MS) }) + .where(eq(issues.id, seeded.sourceIssueId)); + + const sweep = await cooldownService(7).sweepTerminalWorkspaces(); + + expect(sweep).toMatchObject({ archived: 0, skippedCooldown: 1 }); + expect(await statusOf(seeded.executionWorkspaceId)).toBe("active"); + }, 20_000); + }); + it("does not treat an unrelated inbound issue mention as delivery evidence", async () => { const seeded = await seedTerminalWorkspace(); const unrelatedIssueId = randomUUID(); @@ -965,6 +1070,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const racingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async () => { await fs.writeFile(path.join(seeded.worktreePath, "late-work.txt"), "not delivered\n", "utf8"); }, @@ -994,6 +1100,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const racingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async (workspace) => { // Stand in for a reopen and a fresh archive that ran after this sweep // captured the generation. Raise the generation past the captured value, @@ -1597,6 +1704,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const racingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${failSeed.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async (workspace) => { await db .update(executionWorkspaces) @@ -1639,6 +1747,7 @@ describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { const lockingService = executionWorkspaceService(db, { resolvePullRequestDetails: async (_companyId, reference) => pullRequestDetailsByKey.get(`${seeded.companyId}:${reference.number}`) ?? { state: "unknown" }, + workspaceReaperCooldownDays: 0, beforeTerminalWorkspaceCleanup: async () => { try { await runGit(seeded.worktreePath, ["commit", "--allow-empty", "-m", "Late commit"]); diff --git a/server/src/__tests__/workspace-reaper-cooldown-config.test.ts b/server/src/__tests__/workspace-reaper-cooldown-config.test.ts new file mode 100644 index 00000000000..2bcde2f4d69 --- /dev/null +++ b/server/src/__tests__/workspace-reaper-cooldown-config.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadConfig } from "../config.ts"; + +// The terminal-workspace reaper reads PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS. +// These tests lock the parser contract: the default is 7 days, an explicit 0 +// means immediate reaping, and empty, whitespace-only, negative, or non-numeric +// values fall back to the default. An empty or whitespace-only value must not +// become 0, because that would delete terminal workspaces immediately. + +describe("workspace reaper cooldown config parsing", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses the 7 day default when the variable is not set", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", undefined); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("uses the 7 day default for an empty value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", ""); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("uses the 7 day default for a whitespace-only value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", " "); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("keeps an explicit 0 as immediate reaping", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "0"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(0); + }); + + it("reads a positive whole number", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "14"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(14); + }); + + it("trims surrounding whitespace before it reads the number", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", " 3 "); + expect(loadConfig().workspaceReaperCooldownDays).toBe(3); + }); + + it("uses the 7 day default for a negative value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "-1"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); + + it("uses the 7 day default for a non-numeric value", () => { + vi.stubEnv("PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "soon"); + expect(loadConfig().workspaceReaperCooldownDays).toBe(7); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 90d6cbf6b14..332b9d9c898 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -69,6 +69,7 @@ export interface Config { databaseBackupIntervalMinutes: number; databaseBackupRetentionDays: number; databaseBackupDir: string; + workspaceReaperCooldownDays: number; serveUi: boolean; uiDevMiddleware: boolean; secretsProvider: SecretProvider; @@ -265,6 +266,21 @@ export function loadConfig(): Config { fileDatabaseBackup?.dir ?? resolveDefaultBackupDir(), ); + // The terminal-workspace reaper waits this many days after an issue tree + // becomes terminal before it archives the workspace. A person can reopen the + // work inside this window. A value of 0 disables the cooldown and restores + // immediate reaping. A negative or non-numeric value falls back to the + // default. The day granularity and the default of 7 obey the + // PAPERCLIP_DB_BACKUP_RETENTION_DAYS precedent above. + const workspaceReaperCooldownDaysEnv = + process.env.PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS?.trim(); + const workspaceReaperCooldownDaysRaw = Number(workspaceReaperCooldownDaysEnv); + const workspaceReaperCooldownDays = + workspaceReaperCooldownDaysEnv + && Number.isFinite(workspaceReaperCooldownDaysRaw) + && workspaceReaperCooldownDaysRaw >= 0 + ? workspaceReaperCooldownDaysRaw + : 7; const bindValidationErrors = validateConfiguredBindMode({ deploymentMode, deploymentExposure, @@ -307,6 +323,7 @@ export function loadConfig(): Config { databaseBackupIntervalMinutes, databaseBackupRetentionDays, databaseBackupDir, + workspaceReaperCooldownDays, serveUi: process.env.SERVE_UI !== undefined ? process.env.SERVE_UI === "true" diff --git a/server/src/index.ts b/server/src/index.ts index c399f1b8b80..ffbf98ec3de 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1049,7 +1049,9 @@ export async function startServer(): Promise { const mergedPullRequestConfirmations = issueThreadInteractionService(db as any, { wakeup: heartbeat.wakeup, }); - const terminalWorkspaces = executionWorkspaceService(db as any); + const terminalWorkspaces = executionWorkspaceService(db as any, { + workspaceReaperCooldownDays: config.workspaceReaperCooldownDays, + }); const scheduleMergedPullRequestConfirmationSweep = () => { if (heartbeatSchedulerStopped) return; trackHeartbeatSchedulerWork(mergedPullRequestConfirmations @@ -1081,7 +1083,8 @@ export async function startServer(): Promise { result.skippedActiveRun + result.skippedNonTerminalTree + result.skippedUndelivered - + result.skippedRace; + + result.skippedRace + + result.skippedCooldown; const nowMs = Date.now(); if (skipped > 0 && nowMs - lastTerminalWorkspaceSkipLogAt >= terminalWorkspaceSkipLogIntervalMs) { lastTerminalWorkspaceSkipLogAt = nowMs; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 7208bf0827c..360a5042f11 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -67,6 +67,24 @@ type RuntimeServiceReadDb = Pick; type DbTransaction = Parameters[0]>[0]; const execFileAsync = promisify(execFile); const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); + +// Return the timestamp when an issue became terminal. A `done` issue uses +// `completedAt`. A `cancelled` issue uses `cancelledAt`. The reaper cooldown +// measures the age of the terminal transition from this timestamp. Fall back to +// `updatedAt` when the terminal timestamp is null, so an old issue that lacks a +// recorded transition time still gates the cooldown. Return null for a +// non-terminal issue. +function issueTerminalTimestamp(issue: { + status: string; + completedAt: Date | null; + cancelledAt: Date | null; + updatedAt: Date; +}): Date | null { + if (issue.status === "done") return issue.completedAt ?? issue.updatedAt; + if (issue.status === "cancelled") return issue.cancelledAt ?? issue.updatedAt; + return null; +} + const WORKSPACE_BRANCH_INCOHERENCE_REASON = "git_worktree_branch_incoherence"; const WORKSPACE_VALIDATION_RECOVERY_CAUSE = "workspace_validation_failed"; export const ISSUE_TERMINAL_WORKSPACE_CLEANUP_REASON = "issue_terminal"; @@ -208,6 +226,10 @@ export type ExecutionWorkspaceServiceOptions = { resolvePullRequestDetails?: PullRequestMergeDetailsResolver; now?: () => Date; beforeTerminalWorkspaceCleanup?: (workspace: ExecutionWorkspaceRow) => Promise; + // The terminal-workspace reaper waits this many days after an issue tree + // becomes terminal before it archives the workspace. A value of 0 disables + // the cooldown. The default is 7 days. + workspaceReaperCooldownDays?: number; }; function parseGitHubRepository(repoUrl: string | null) { @@ -1230,6 +1252,14 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const recoveryActionsSvc = issueRecoveryActionService(db); const resolvePullRequestDetails = opts.resolvePullRequestDetails ?? createPullRequestMergeDetailsResolver(db); const now = opts.now ?? (() => new Date()); + // The reaper waits this long after an issue tree becomes terminal before it + // archives the workspace. A value of 0 disables the cooldown, so the reaper + // archives a terminal workspace on the same sweep. A negative value also + // disables the cooldown. + const workspaceReaperCooldownMs = Math.max( + 0, + (opts.workspaceReaperCooldownDays ?? 7) * 24 * 60 * 60 * 1000, + ); const pullRequestStateCache = new Map< string, { @@ -1268,6 +1298,9 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .select({ id: issues.id, status: issues.status, + completedAt: issues.completedAt, + cancelledAt: issues.cancelledAt, + updatedAt: issues.updatedAt, }) .from(issues) .where(and( @@ -1323,6 +1356,17 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic const sourceIssue = issueTree.find((issue) => issue.id === workspace.sourceIssueId) ?? null; const sourceIssueTerminal = Boolean(sourceIssue && TERMINAL_ISSUE_STATUSES.has(sourceIssue.status)); const subtreeTerminal = Boolean(sourceIssue && issueTree.every((issue) => TERMINAL_ISSUE_STATUSES.has(issue.status))); + // The cooldown anchor is the most recent terminal timestamp across the whole + // issue tree. The reaper compares it against the cooldown window. A null + // anchor means no issue in the tree is terminal yet, so the cooldown never + // applies (the terminal-tree gates above already block the archive). + let cooldownAnchor: Date | null = null; + for (const issue of issueTree) { + const terminalAt = issueTerminalTimestamp(issue); + if (terminalAt && (!cooldownAnchor || terminalAt.getTime() > cooldownAnchor.getTime())) { + cooldownAnchor = terminalAt; + } + } let mergedPullRequest = false; let pullRequestStateUnknown = false; const workspaceHeadSha = git?.repoRoot && git.workspacePath @@ -1386,6 +1430,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic }), sourceIssueTerminal, subtreeTerminal, + cooldownAnchor, workspaceDirty: Boolean(git?.hasDirtyTrackedFiles || git?.hasUntrackedFiles), workspaceHeadSha, }; @@ -2460,6 +2505,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic skippedUndelivered: 0, skippedRace: 0, skippedReopened: 0, + skippedCooldown: 0, clearedStaleReopenPending: 0, }; } @@ -2523,6 +2569,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic skippedUndelivered: 0, skippedRace: 0, skippedReopened: 0, + skippedCooldown: 0, clearedStaleReopenPending: 0, }; @@ -2564,6 +2611,23 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic result.skippedUndelivered += 1; continue; } + // Hold the archive during the cooldown window. The anchor is the most + // recent terminal timestamp across the issue tree. A person can reopen + // the work inside this window. A cooldown of 0 disables the check, so the + // reaper archives the workspace on the same sweep. The archive statement + // below re-checks the same cutoff under the lifecycle lock, so the loop + // check and the guarded statement agree. + const cooldownCutoff = workspaceReaperCooldownMs > 0 + ? new Date(now().getTime() - workspaceReaperCooldownMs) + : null; + if ( + cooldownCutoff + && assessment.cooldownAnchor + && assessment.cooldownAnchor.getTime() > cooldownCutoff.getTime() + ) { + result.skippedCooldown += 1; + continue; + } if (reopenPending) { const pendingSince = readMetadataReopenPendingConsumptionSince( workspace.metadata as Record | null, @@ -2689,6 +2753,34 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic ) SELECT 1 FROM issue_tree WHERE status NOT IN ('done', 'cancelled') )`, + // Re-check the cooldown under the lifecycle lock. This predicate + // matches the loop check above: block the archive when any issue in + // the tree became terminal after the cutoff. The tree walk mirrors + // the terminal-tree walk above. A null cutoff means the cooldown is + // disabled, so this predicate drops out of the guard. + cooldownCutoff + ? sql`NOT EXISTS ( + WITH RECURSIVE cooldown_tree(id, status, completed_at, cancelled_at, updated_at) AS ( + SELECT root.id, root.status, root.completed_at, root.cancelled_at, root.updated_at + FROM ${issues} root + WHERE root.company_id = ${workspace.companyId} + AND root.id = ${workspace.sourceIssueId} + UNION ALL + SELECT child.id, child.status, child.completed_at, child.cancelled_at, child.updated_at + FROM ${issues} child + JOIN cooldown_tree parent ON child.parent_id = parent.id + WHERE child.company_id = ${workspace.companyId} + ) + SELECT 1 FROM cooldown_tree + WHERE COALESCE( + CASE + WHEN status = 'done' THEN completed_at + WHEN status = 'cancelled' THEN cancelled_at + END, + updated_at + ) > ${cooldownCutoff.toISOString()}::timestamptz + )` + : undefined, )) .returning() .then((rows) => rows[0] ?? null); From 120ae5428fa29bee300bcf806491cd4d965fbb7c Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Tue, 18 Aug 2026 11:43:39 -0700 Subject: [PATCH 05/39] feat(server): add a one-click relink action for detached custom-image templates (#11641) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip environments can use captured custom images for agent runs > - A configuration fingerprint change can detach a valid custom-image template > - Operators need a safe way to confirm that the image still matches the boot source > - This pull request adds a guarded relink action with drift classification and audit logging > - The benefit is a deliberate relink without a new sandbox boot or provider snapshot ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting environment, server, and UI behavior. **Problem or motivation** A custom-image template detaches when the environment configuration fingerprint changes. The runtime then uses the base image, even when the boot source did not change. The only prior remedy required a full re-capture. **Proposed solution** Add an operator-triggered relink action. Classify configuration drift from a server-owned boot-relevant snapshot. Relink knob-only drift without confirmation. Require explicit confirmation for boot-source or unclassified drift. Guard the route for instance administrators and record a safe activity event. **Alternatives considered** Keep requiring a full re-capture. This adds a sandbox boot and provider snapshot for cases where the image remains correct. **Roadmap alignment** The roadmap has no matching custom-image relink item. This change addresses an environment operation gap. **Additional context** The relink response exposes raw drift values only in the transient 409 response to the instance administrator. The service never persists or logs fingerprints or configuration values. Reserved identity-path segments fail closed. ## What Changed - Add `relinkActiveTemplate` with drift classification and conditional fingerprint update. - Persist a server-owned boot-relevant configuration snapshot during capture. - Add the guarded relink route with strict request validation and activity logging. - Add the relink action and confirmation flow to the environment page. - Add service, route, UI, and OpenAPI coverage. ## Verification - Run the focused service suite: `pnpm vitest run server/src/services/environment-custom-images-service.test.ts`. - Run the focused route suite: `pnpm vitest run server/src/routes/environment-custom-image-routes.test.ts`. - Run the focused UI suite: `pnpm vitest run ui/src/pages/CompanyEnvironments.test.tsx`. - Run server and UI TypeScript checks. - Confirm the OpenAPI snapshot matches the new route. - Confirm all required GitHub checks pass on commit `e46fdcfe94a719be854adf8849d30714e5b70b93`. - Confirm Greptile reports 5/5 with no unresolved review threads. ## Risks The relink action can keep an image after configuration drift. The service requires explicit confirmation for boot-source or unclassified drift. Reserved path segments produce a safe unresolved marker and never enter stored values. ## Model Used OpenAI GPT-5 Codex. The model used repository inspection, GitHub operations, and PR preparation with tool use and code execution. The runtime did not expose a context-window value or a separate reasoning-mode 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 - [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/index.ts | 2 + .../validators/environment-custom-images.ts | 6 + packages/shared/src/validators/index.ts | 2 + .../environment-custom-image-routes.test.ts | 80 +++ .../environment-custom-images-service.test.ts | 501 +++++++++++++++++- server/src/routes/environments.ts | 26 + server/src/routes/openapi.ts | 26 + .../environment-custom-image-runtime.ts | 311 ++++++++++- .../src/services/environment-custom-images.ts | 189 ++++++- ui/src/api/environments.ts | 35 ++ ui/src/pages/CompanyEnvironments.test.tsx | 136 +++++ ui/src/pages/CompanyEnvironments.tsx | 94 +++- 12 files changed, 1393 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fb079ee85d7..f4ac04960d8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2438,6 +2438,7 @@ export { startEnvironmentCustomImageSetupSessionSchema, finishEnvironmentCustomImageSetupSessionSchema, cancelEnvironmentCustomImageSetupSessionSchema, + relinkEnvironmentCustomImageTemplateSchema, createEnvironmentCustomImageTerminalSessionTokenSchema, environmentCustomImageTerminalSessionTokenSchema, type EnvironmentCustomImageSetupConnectionSummary, @@ -2446,6 +2447,7 @@ export { type StartEnvironmentCustomImageSetupSession, type FinishEnvironmentCustomImageSetupSession, type CancelEnvironmentCustomImageSetupSession, + type RelinkEnvironmentCustomImageTemplate, type CreateEnvironmentCustomImageTerminalSessionToken, type EnvironmentCustomImageTerminalSessionToken, } from "./validators/environment-custom-images.js"; diff --git a/packages/shared/src/validators/environment-custom-images.ts b/packages/shared/src/validators/environment-custom-images.ts index 07eb945f200..9ef97e3d690 100644 --- a/packages/shared/src/validators/environment-custom-images.ts +++ b/packages/shared/src/validators/environment-custom-images.ts @@ -94,6 +94,12 @@ export const cancelEnvironmentCustomImageSetupSessionSchema = z.object({ export type CancelEnvironmentCustomImageSetupSession = z.infer; +export const relinkEnvironmentCustomImageTemplateSchema = z.object({ + confirmBootSourceDrift: z.boolean().optional().default(false), +}).strict(); +export type RelinkEnvironmentCustomImageTemplate = + z.infer; + export const createEnvironmentCustomImageTerminalSessionTokenSchema = z.object({}).strict().default({}); export type CreateEnvironmentCustomImageTerminalSessionToken = z.infer; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 3b939c21089..2fc001d5a40 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -89,6 +89,7 @@ export { startEnvironmentCustomImageSetupSessionSchema, finishEnvironmentCustomImageSetupSessionSchema, cancelEnvironmentCustomImageSetupSessionSchema, + relinkEnvironmentCustomImageTemplateSchema, createEnvironmentCustomImageTerminalSessionTokenSchema, environmentCustomImageTerminalSessionTokenSchema, type EnvironmentCustomImageSetupConnectionSummary, @@ -97,6 +98,7 @@ export { type StartEnvironmentCustomImageSetupSession, type FinishEnvironmentCustomImageSetupSession, type CancelEnvironmentCustomImageSetupSession, + type RelinkEnvironmentCustomImageTemplate, type CreateEnvironmentCustomImageTerminalSessionToken, type EnvironmentCustomImageTerminalSessionToken, } from "./environment-custom-images.js"; diff --git a/server/src/__tests__/environment-custom-image-routes.test.ts b/server/src/__tests__/environment-custom-image-routes.test.ts index 649055adebd..82b8eda9d37 100644 --- a/server/src/__tests__/environment-custom-image-routes.test.ts +++ b/server/src/__tests__/environment-custom-image-routes.test.ts @@ -2,6 +2,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { environmentRoutes } from "../routes/environments.js"; +import { HttpError } from "../errors.js"; import { environmentCustomImageTerminalConnectionRegistry, environmentCustomImageTerminalSessionStore, @@ -40,6 +41,7 @@ const mockEnvironmentCustomImageService = vi.hoisted(() => ({ finishSetupSession: vi.fn(), cancelSetupSession: vi.fn(), rollbackTemplate: vi.fn(), + relinkActiveTemplate: vi.fn(), disableTemplate: vi.fn(), cleanupExpiredSetupSessions: vi.fn(), })); @@ -292,6 +294,10 @@ describe("environment customImage setup routes", () => { templateRef: "disabled-template-secret", status: "revoked", })); + mockEnvironmentCustomImageService.relinkActiveTemplate.mockResolvedValue({ + template: createTemplate({ id: "template-1", templateRef: "relinked-template-secret" }), + classification: "knob_only", + }); }); it("starts a setup session, returns the live payload, and logs redacted details", async () => { @@ -641,4 +647,78 @@ describe("environment customImage setup routes", () => { expect(activity).not.toContain("old-template-secret"); expect(activity).not.toContain("disabled-template-secret"); }); + + it("relinks the active template through the company-scoped route", async () => { + const res = await request(createApp(boardActor())) + .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1") + .send({ confirmBootSourceDrift: true }); + + expect(res.status).toBe(200); + expect(res.body.classification).toBe("knob_only"); + expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledWith({ + environmentId: "env-1", + confirmBootSourceDrift: true, + actor: { + actorType: "user", + actorId: "user-1", + agentId: null, + runId: null, + agentApiKeyId: null, + }, + companyId: "company-1", + }); + }); + + it("defaults the confirmation flag to false and rejects unknown body keys", async () => { + const app = createApp(boardActor()); + const withoutFlag = await request(app) + .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1") + .send({}); + expect(withoutFlag.status).toBe(200); + expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ confirmBootSourceDrift: false }), + ); + + // The strict schema rejects unknown keys before the handler runs, so the + // relink service is never reached for the malformed body. + const unknownKey = await request(app) + .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1") + .send({ confirmBootSourceDrift: false, unexpected: true }); + expect(unknownKey.status).not.toBe(200); + expect(mockEnvironmentCustomImageService.relinkActiveTemplate).toHaveBeenCalledTimes(1); + }); + + it("propagates the drift conflict from the relink service", async () => { + mockEnvironmentCustomImageService.relinkActiveTemplate.mockImplementationOnce(async () => { + throw new HttpError(409, "Confirm the relink to keep the captured snapshot.", { + classification: "boot_source_drift", + driftedPaths: [{ path: "image", from: "fake:base", to: "fake:other" }], + }); + }); + + const res = await request(createApp(boardActor())) + .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1") + .send({}); + + expect(res.status).toBe(409); + }); + + it("denies agent API key actors before the relink service is called", async () => { + const res = await request(createApp(agentActor())) + .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1") + .send({}); + expect(res.status).toBe(403); + expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled(); + }); + + it("denies non-admin board users before the relink service is called", async () => { + const res = await request(createApp(boardActor({ + companyIds: ["company-2"], + isInstanceAdmin: false, + }))) + .post("/api/environments/env-1/custom-image-template/relink?companyId=company-1") + .send({}); + expect(res.status).toBe(403); + expect(mockEnvironmentCustomImageService.relinkActiveTemplate).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/environment-custom-images-service.test.ts b/server/src/__tests__/environment-custom-images-service.test.ts index 51a8e4691ad..335c1e61424 100644 --- a/server/src/__tests__/environment-custom-images-service.test.ts +++ b/server/src/__tests__/environment-custom-images-service.test.ts @@ -1,7 +1,8 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import { eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { + activityLog, companies, createDb, environmentCustomImageSetupSessions, @@ -866,3 +867,501 @@ describeEmbeddedPostgres("environmentCustomImageService reconciliation", () => { expect(outOfSync.activeTemplateMatchesConfig).toBe(false); }); }); + +function secretPluginManifest() { + return { + id: "paperclip.fake-secret-sandbox-provider", + apiVersion: 1, + version: "0.1.0", + displayName: "Fake Secret Sandbox Provider", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "./dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "fake-secret-plugin", + kind: "sandbox_provider", + displayName: "Fake Secret Sandbox Provider", + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] }, + // apiUrl overlaps a secret exactly; auth.token is a child of secret + // `auth`; credentials is a parent of secret `credentials.secret`. + templateIdentityPaths: ["apiUrl", "auth.token", "credentials"], + supportsTemplateDelete: true, + configSchema: { + type: "object", + properties: { + apiUrl: { type: "string", format: "secret-ref" }, + auth: { type: "string", format: "secret-ref" }, + credentials: { + type: "object", + properties: { secret: { type: "string", format: "secret-ref" } }, + }, + }, + }, + }, + ], + } as const; +} + +function invalidIdentityPluginManifest() { + return { + id: "paperclip.fake-invalid-sandbox-provider", + apiVersion: 1, + version: "0.1.0", + displayName: "Fake Invalid Identity Sandbox Provider", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "./dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "fake-invalid-plugin", + kind: "sandbox_provider", + displayName: "Fake Invalid Identity Sandbox Provider", + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] }, + // A valid path plus two that must fail canonicalization. + templateIdentityPaths: ["apiUrl", "bad path!", "a..b"], + supportsTemplateDelete: true, + configSchema: { type: "object" }, + }, + ], + } as const; +} + +function prototypeKeyIdentityPluginManifest() { + return { + id: "paperclip.fake-prototype-key-sandbox-provider", + apiVersion: 1, + version: "0.1.0", + displayName: "Fake Prototype Key Sandbox Provider", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "./dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "fake-prototype-key-plugin", + kind: "sandbox_provider", + displayName: "Fake Prototype Key Sandbox Provider", + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + templateRefKind: "snapshot", + templateConfigBinding: { field: "customTemplate", unsetFields: ["image"] }, + // Every prototype key has an identifier shape but names no own field. + // Each must fail canonicalization and record only the marker. + templateIdentityPaths: ["__proto__", "constructor", "prototype", "nested.__proto__"], + supportsTemplateDelete: true, + configSchema: { type: "object" }, + }, + ], + } as const; +} + +describeEmbeddedPostgres("environmentCustomImageService relink", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + + beforeAll(async () => { + const started = await startEmbeddedPostgresTestDatabase("environment-custom-images-relink"); + stopDb = started.stop; + db = createDb(started.connectionString); + }); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(environmentCustomImageSetupSessions); + await db.delete(environmentCustomImageTemplates); + await db.delete(plugins); + await db.delete(environments); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + }); + + async function seed(opts?: { + manifest?: ReturnType | ReturnType | ReturnType | ReturnType; + config?: Record; + }) { + const manifest = opts?.manifest ?? pluginManifest(); + const provider = manifest.environmentDrivers[0]!.driverKey; + const companyId = randomUUID(); + const environmentId = randomUUID(); + await db.insert(companies).values( + { id: companyId, name: "Acme", issuePrefix: `A${companyId.slice(0, 4)}` }, + ); + await db.insert(environments).values({ + id: environmentId, + name: `Fake ${environmentId.slice(0, 8)}`, + driver: "sandbox", + status: "active", + config: opts?.config ?? { provider, image: "fake:base", reuseLease: false }, + envVars: {}, + }); + await db.insert(plugins).values({ + pluginKey: manifest.id, + packageName: `paperclip-plugin-${provider}`, + version: "0.1.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: manifest, + status: "ready", + }); + return { companyId, environmentId, provider }; + } + + const relinkActor = { + actorType: "user" as const, + actorId: "user-1", + agentId: null, + runId: null, + agentApiKeyId: null, + }; + + async function activeTemplateRow(environmentId: string) { + return db + .select() + .from(environmentCustomImageTemplates) + .where(eq(environmentCustomImageTemplates.environmentId, environmentId)) + .orderBy(desc(environmentCustomImageTemplates.createdAt)) + .then((rows) => rows[0]!); + } + + async function relinkActivityRows(environmentId: string) { + return db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, environmentId)); + } + + it("keeps secret-ref values out of metadata, the response, and activity, and relinks with the flag", async () => { + const config = { + provider: "fake-secret-plugin", + image: "fake:base", + apiUrl: "https://secret-endpoint.example", + auth: "auth-secret-value", + credentials: { secret: "cred-secret-value", region: "eu" }, + reuseLease: false, + }; + const { companyId, environmentId } = await seed({ manifest: secretPluginManifest(), config }); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + + // The public template response never carries the server-internal snapshot. + expect((promoted.template.metadata as Record).bootRelevantConfig).toBeUndefined(); + + // The persisted snapshot lives in the row. Every secret-ref overlap is + // excluded with no value (exact, child, parent). + const persistedRow = await activeTemplateRow(environmentId); + const boot = (persistedRow.metadata as Record).bootRelevantConfig as { + values: Record; + excludedPaths: string[]; + }; + expect(boot.excludedPaths).toEqual(expect.arrayContaining(["apiUrl", "auth.token", "credentials"])); + expect(Object.keys(boot.values)).not.toContain("apiUrl"); + expect(Object.keys(boot.values)).not.toContain("auth.token"); + expect(Object.keys(boot.values)).not.toContain("credentials"); + // Neither the persisted snapshot nor the response leaks a secret value. + for (const metadataJson of [ + JSON.stringify(persistedRow.metadata), + JSON.stringify(promoted.template.metadata), + ]) { + expect(metadataJson).not.toContain("secret-endpoint.example"); + expect(metadataJson).not.toContain("auth-secret-value"); + expect(metadataJson).not.toContain("cred-secret-value"); + } + + // An excluded path forces the fail-closed unclassified result: the flag is required. + await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId })) + .rejects.toMatchObject({ status: 409 }); + const relinked = await service.relinkActiveTemplate({ + environmentId, + confirmBootSourceDrift: true, + actor: relinkActor, + companyId, + }); + expect(relinked.classification).toBe("unclassified"); + const responseJson = JSON.stringify(relinked); + expect(responseJson).not.toContain("secret-endpoint.example"); + expect(responseJson).not.toContain("auth-secret-value"); + expect(responseJson).not.toContain("cred-secret-value"); + + const activities = await relinkActivityRows(environmentId); + const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked"); + expect(relinkActivity).toBeDefined(); + const activityJson = JSON.stringify(relinkActivity!.details); + expect(activityJson).not.toContain("secret-endpoint.example"); + expect(activityJson).not.toContain("auth-secret-value"); + expect(activityJson).not.toContain("cred-secret-value"); + // Drift detail carries path names only, never a fingerprint value. + expect(activityJson).not.toContain(promoted.template.sourceEnvironmentConfigFingerprint); + }); + + it("fails closed for legacy templates without a boot-relevant snapshot", async () => { + const { companyId, environmentId } = await seed(); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + await db.insert(environmentCustomImageTemplates).values({ + environmentId, + provider: "fake-plugin", + templateKind: "snapshot", + templateRef: "snapshot-legacy", + sourceEnvironmentConfigFingerprint: "stale-fingerprint", + status: "active", + metadata: { runtimeConfigBinding: { field: "customTemplate", unsetFields: ["image"] } }, + }); + + await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId })) + .rejects.toMatchObject({ status: 409 }); + const beforeRow = await activeTemplateRow(environmentId); + expect(beforeRow.sourceEnvironmentConfigFingerprint).toBe("stale-fingerprint"); + + const relinked = await service.relinkActiveTemplate({ + environmentId, + confirmBootSourceDrift: true, + actor: relinkActor, + companyId, + }); + expect(relinked.classification).toBe("unclassified"); + const afterRow = await activeTemplateRow(environmentId); + expect(afterRow.sourceEnvironmentConfigFingerprint).not.toBe("stale-fingerprint"); + }); + + it("relinks a knob-only change without a flag and keeps the fail-closed runtime gate", async () => { + const { companyId, environmentId } = await seed(); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + + // A non-boot field changes the fingerprint and detaches the template. + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } }) + .where(eq(environments.id, environmentId)); + const detached = await service.getOverview({ environmentId }); + expect(detached.activeTemplateMatchesConfig).toBe(false); + + const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }); + expect(relinked.classification).toBe("knob_only"); + const afterRelink = await service.getOverview({ environmentId }); + expect(afterRelink.activeTemplateMatchesConfig).toBe(true); + const resolved = await resolveEnvironmentDriverConfigForRuntime(db, companyId, { + id: environmentId, + driver: "sandbox", + config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" }, + }, { heartbeatRunId: randomUUID() }); + expect(resolved.config).toMatchObject({ customTemplate: promoted.template.templateRef }); + + // The gate stays fail-closed: a base image change detaches again. + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false, region: "eu" } }) + .where(eq(environments.id, environmentId)); + const afterImageChange = await service.getOverview({ environmentId }); + expect(afterImageChange.activeTemplateMatchesConfig).toBe(false); + }); + + it("requires the flag for boot-source drift and re-stamps only after confirmation", async () => { + const { companyId, environmentId } = await seed(); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + await service.finishSetupSession({ sessionId: started.session.id }); + + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:other", reuseLease: false } }) + .where(eq(environments.id, environmentId)); + + await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId })) + .rejects.toMatchObject({ + status: 409, + details: { + classification: "boot_source_drift", + driftedPaths: expect.arrayContaining([ + expect.objectContaining({ path: "image", from: "fake:base", to: "fake:other" }), + ]), + }, + }); + + const relinked = await service.relinkActiveTemplate({ + environmentId, + confirmBootSourceDrift: true, + actor: relinkActor, + companyId, + }); + expect(relinked.classification).toBe("boot_source_drift"); + const afterRelink = await service.getOverview({ environmentId }); + expect(afterRelink.activeTemplateMatchesConfig).toBe(true); + }); + + it("re-stamps only the active template and never a superseded one", async () => { + const { companyId, environmentId } = await seed(); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + const first = await service.finishSetupSession({ + sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id, + }); + const second = await service.finishSetupSession({ + sessionId: (await service.startSetupSession({ environmentId, actor: { userId: "user-1" } })).session.id, + }); + // The first template is now superseded; the second is active. + const supersededBefore = await db + .select() + .from(environmentCustomImageTemplates) + .where(eq(environmentCustomImageTemplates.id, first.template.id)) + .then((rows) => rows[0]!); + + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:base", reuseLease: false, region: "eu" } }) + .where(eq(environments.id, environmentId)); + const relinked = await service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId }); + expect(relinked.template.id).toBe(second.template.id); + + const supersededAfter = await db + .select() + .from(environmentCustomImageTemplates) + .where(eq(environmentCustomImageTemplates.id, first.template.id)) + .then((rows) => rows[0]!); + expect(supersededAfter.status).toBe("superseded"); + expect(supersededAfter.sourceEnvironmentConfigFingerprint) + .toBe(supersededBefore.sourceEnvironmentConfigFingerprint); + }); + + it("aborts with a conflict and writes no active template when none exists", async () => { + const { companyId, environmentId } = await seed(); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId })) + .rejects.toMatchObject({ status: 404 }); + const activities = await relinkActivityRows(environmentId); + expect(activities.filter((row) => row.action === "environment.custom_image_template.relinked")).toHaveLength(0); + }); + + it("classifies an invalid driver identity path as unclassified without treating it as value-bearing", async () => { + const { companyId, environmentId } = await seed({ manifest: invalidIdentityPluginManifest(), config: { + provider: "fake-invalid-plugin", + image: "fake:base", + reuseLease: false, + } }); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + + expect((promoted.template.metadata as Record).bootRelevantConfig).toBeUndefined(); + const persistedRow = await activeTemplateRow(environmentId); + const boot = (persistedRow.metadata as Record).bootRelevantConfig as { + values: Record; + excludedPaths: string[]; + }; + expect(boot.excludedPaths).toContain("[unresolved-identity-path]"); + // The raw invalid paths are never persisted and never value-bearing. + const metadataJson = JSON.stringify(persistedRow.metadata); + expect(metadataJson).not.toContain("bad path!"); + expect(metadataJson).not.toContain("a..b"); + expect(Object.keys(boot.values)).not.toContain("bad path!"); + expect(Object.keys(boot.values)).not.toContain("a..b"); + + await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId })) + .rejects.toMatchObject({ status: 409 }); + const relinked = await service.relinkActiveTemplate({ + environmentId, + confirmBootSourceDrift: true, + actor: relinkActor, + companyId, + }); + expect(relinked.classification).toBe("unclassified"); + }); + + it("fails closed for prototype-key driver identity paths and never persists them", async () => { + const { companyId, environmentId } = await seed({ manifest: prototypeKeyIdentityPluginManifest(), config: { + provider: "fake-prototype-key-plugin", + image: "fake:base", + reuseLease: false, + } }); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + const promoted = await service.finishSetupSession({ sessionId: started.session.id }); + + expect((promoted.template.metadata as Record).bootRelevantConfig).toBeUndefined(); + const persistedRow = await activeTemplateRow(environmentId); + const boot = (persistedRow.metadata as Record).bootRelevantConfig as { + values: Record; + excludedPaths: string[]; + }; + // Every prototype key fails canonicalization and records only the marker. + expect(boot.excludedPaths).toContain("[unresolved-identity-path]"); + // No raw prototype-key path and no value for one persists in the snapshot. + expect(boot.excludedPaths).not.toContain("__proto__"); + expect(boot.excludedPaths).not.toContain("constructor"); + expect(boot.excludedPaths).not.toContain("prototype"); + expect(Object.keys(boot.values)).not.toContain("__proto__"); + expect(Object.keys(boot.values)).not.toContain("constructor"); + expect(Object.keys(boot.values)).not.toContain("prototype"); + // The prototype of the values map stays clean; no key leaked onto it. + expect(Object.getPrototypeOf(boot.values)).toBe(Object.prototype); + const metadataJson = JSON.stringify(persistedRow.metadata); + expect(metadataJson).not.toContain("__proto__"); + expect(metadataJson).not.toContain("nested.__proto__"); + + // An unflagged relink fails closed with 409 and the unclassified class. + await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId })) + .rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } }); + // A confirmed relink succeeds and writes the relinked activity entry. + const relinked = await service.relinkActiveTemplate({ + environmentId, + confirmBootSourceDrift: true, + actor: relinkActor, + companyId, + }); + expect(relinked.classification).toBe("unclassified"); + const activities = await relinkActivityRows(environmentId); + const relinkActivity = activities.find((row) => row.action === "environment.custom_image_template.relinked"); + expect(relinkActivity).toBeDefined(); + }); + + it("fails closed when the provider adds a boot-relevant identity path after capture", async () => { + const { companyId, environmentId } = await seed({ config: { + provider: "fake-plugin", + image: "fake:base", + region: "us", + reuseLease: false, + } }); + const service = environmentCustomImageService(db, { pluginWorkerManager: createWorkerManager() }); + const started = await service.startSetupSession({ environmentId, actor: { userId: "user-1" } }); + await service.finishSetupSession({ sessionId: started.session.id }); + + // A knob-only region change detaches the template. On its own it would + // relink without confirmation. + await db.update(environments) + .set({ config: { provider: "fake-plugin", image: "fake:base", region: "eu", reuseLease: false } }) + .where(eq(environments.id, environmentId)); + + // The provider now declares `region` a boot-relevant identity path. The + // capture snapshot never covered it, so the server cannot verify the boot + // source and must not classify the drift as knob-only. + const manifest = pluginManifest(); + const nextManifest = { + ...manifest, + environmentDrivers: [ + { ...manifest.environmentDrivers[0], templateIdentityPaths: ["apiUrl", "region"] }, + ], + }; + await db.update(plugins) + .set({ manifestJson: nextManifest }) + .where(eq(plugins.pluginKey, manifest.id)); + + await expect(service.relinkActiveTemplate({ environmentId, actor: relinkActor, companyId })) + .rejects.toMatchObject({ status: 409, details: { classification: "unclassified" } }); + const relinked = await service.relinkActiveTemplate({ + environmentId, + confirmBootSourceDrift: true, + actor: relinkActor, + companyId, + }); + expect(relinked.classification).toBe("unclassified"); + }); +}); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 834f1e2407a..38fe085738f 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -11,6 +11,7 @@ import { resolveDeclaredSandboxCapabilities, redactEnvironmentCustomImageSetupSession, redactEnvironmentCustomImageTemplate, + relinkEnvironmentCustomImageTemplateSchema, startEnvironmentCustomImageSetupSessionSchema, type EnvironmentDeleteBlastRadius, updateEnvironmentSchema, @@ -952,6 +953,31 @@ export function environmentRoutes( res.json(result); }); + router.post( + "/environments/:environmentId/custom-image-template/relink", + validate(relinkEnvironmentCustomImageTemplateSchema), + async (req, res) => { + assertCanAccessInstanceEnvironments(req); + const companyId = await resolveCustomImageCompanyId(req); + const actor = getActorInfo(req); + // The service classifies drift, re-stamps the fingerprint, and writes the + // activity row in one transaction. The route never classifies. + const result = await customImages.relinkActiveTemplate({ + environmentId: req.params.environmentId as string, + confirmBootSourceDrift: req.body.confirmBootSourceDrift === true, + actor: { + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + }, + companyId, + }); + res.json(result); + }, + ); + router.delete("/environments/:environmentId/custom-image-template", async (req, res) => { assertCanAccessInstanceEnvironments(req); const companyId = await resolveCustomImageCompanyId(req); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 9e47791a5fc..e225b84da03 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -112,6 +112,7 @@ import { environmentCustomImageTerminalSessionTokenSchema, environmentCustomImageTemplateSchema, finishEnvironmentCustomImageSetupSessionSchema, + relinkEnvironmentCustomImageTemplateSchema, updateEnvironmentSchema, probeEnvironmentConfigSchema, startEnvironmentCustomImageSetupSessionSchema, @@ -635,6 +636,11 @@ const environmentCustomImageTemplateRollbackResultSchema = z.object({ supersededTemplate: environmentCustomImageTemplateSchema, }).strict(); +const environmentCustomImageTemplateRelinkResultSchema = z.object({ + template: environmentCustomImageTemplateSchema, + classification: z.enum(["knob_only", "boot_source_drift", "unclassified"]), +}).strict(); + const workTimelineQuerySchema = z.object({ from: z.string().optional(), to: z.string().optional(), @@ -5585,6 +5591,26 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "post", + path: "/api/environments/{environmentId}/custom-image-template/relink", + tags: ["environments"], + summary: "Relink a detached environment customImage template to the current config", + request: { + params: z.object({ environmentId: z.string() }), + query: environmentCustomImageCompanyQuerySchema, + body: jsonBody(relinkEnvironmentCustomImageTemplateSchema), + }, + responses: { + 200: r.ok(environmentCustomImageTemplateRelinkResultSchema), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + }, +}); + registry.registerPath({ method: "delete", path: "/api/environments/{environmentId}/custom-image-template", diff --git a/server/src/services/environment-custom-image-runtime.ts b/server/src/services/environment-custom-image-runtime.ts index 254169290c8..996770ef3f7 100644 --- a/server/src/services/environment-custom-image-runtime.ts +++ b/server/src/services/environment-custom-image-runtime.ts @@ -219,6 +219,313 @@ export function classifyEnvironmentCustomImageConfigChange(input: { return "relinkable"; } +// --- Operator relink action (server-owned boot-source classification) -------- +// +// A saved config change moves the fingerprint and detaches an otherwise-valid +// template. The relink action re-stamps the fingerprint, but only after the +// server decides the boot source did not change. To decide that without the +// capture-time config, the capture persists a small, server-owned snapshot of +// the boot-relevant fields. The relink then compares that snapshot to the +// current config. The comparison runs on the server; the client never +// classifies. + +export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY = "bootRelevantConfig"; +export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION = 1; +// A driver-declared identity path that fails canonicalization is never trusted +// and never persisted verbatim. It records this fixed marker instead, which +// forces the fail-closed `unclassified` result at relink time. +export const ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_UNRESOLVED_PATH = "[unresolved-identity-path]"; + +const BOOT_RELEVANT_CONFIG_MAX_PATH_DEPTH = 8; +const BOOT_RELEVANT_CONFIG_PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/; +// Prototype keys have an identifier shape but never name an own config field. +// A driver that declares one is hostile or broken. Reject it so the path fails +// closed as an unresolved identity path. +const BOOT_RELEVANT_CONFIG_RESERVED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]); + +export type EnvironmentCustomImageRelinkClassification = + | "knob_only" + | "boot_source_drift" + | "unclassified"; + +/** + * Server-owned snapshot of the boot-relevant config fields at capture time. It + * carries the field values that decide the boot source, with every secret-ref + * path and every unresolvable driver path excluded (path name only, no value). + */ +export interface EnvironmentCustomImageBootRelevantConfig { + version: typeof ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION; + provider: string; + bindingField: string; + values: Record; + excludedPaths: string[]; +} + +export interface EnvironmentCustomImageDriftedPath { + path: string; + from?: unknown; + to?: unknown; +} + +/** + * Canonicalizes a config path as a bounded dot-path. Each segment must be a + * plain identifier and the depth is bounded. An invalid, ambiguous, or + * unresolvable path returns `null`; the caller must never persist the raw + * driver-supplied string. + */ +export function canonicalizeEnvironmentCustomImageConfigPath(raw: unknown): string | null { + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + const segments = trimmed.split("."); + if (segments.length === 0 || segments.length > BOOT_RELEVANT_CONFIG_MAX_PATH_DEPTH) return null; + for (const segment of segments) { + if (!BOOT_RELEVANT_CONFIG_PATH_SEGMENT_RE.test(segment)) return null; + if (BOOT_RELEVANT_CONFIG_RESERVED_SEGMENTS.has(segment)) return null; + } + return segments.join("."); +} + +/** + * Reports whether a candidate path overlaps any secret-ref path by exact, + * ancestor, or descendant containment. + */ +function environmentCustomImageConfigPathOverlapsSecret( + candidate: string, + secretPaths: Iterable, +): boolean { + for (const raw of secretPaths) { + const secret = canonicalizeEnvironmentCustomImageConfigPath(raw); + if (!secret) continue; + if (candidate === secret) return true; + if (candidate.startsWith(`${secret}.`)) return true; + if (secret.startsWith(`${candidate}.`)) return true; + } + return false; +} + +/** + * The current boot-relevant path contract a provider driver declares. It names + * the config binding and the driver identity paths that decide the boot source + * now. A relink compares a persisted snapshot against this contract to detect a + * provider change since capture. + */ +export interface EnvironmentCustomImageBootRelevantContract { + binding: EnvironmentCustomImageRuntimeConfigBinding; + templateIdentityPaths?: Iterable; +} + +/** + * Computes the canonical candidate boot-relevant paths for a binding and its + * driver identity paths. Both the capture snapshot and the relink staleness + * check use this function, so they share one path set. `hasUnresolved` is true + * when any raw path fails canonicalization. + */ +export function environmentCustomImageBootRelevantCandidatePaths( + contract: EnvironmentCustomImageBootRelevantContract, +): { canonicalPaths: string[]; hasUnresolved: boolean } { + const canonicalPaths: string[] = []; + const seen = new Set(); + let hasUnresolved = false; + const rawCandidatePaths = [ + contract.binding.field, + ...contract.binding.unsetFields, + ...ENVIRONMENT_CUSTOM_IMAGE_TEMPLATE_SOURCE_FIELDS, + ...(contract.templateIdentityPaths ?? []), + ]; + for (const rawPath of rawCandidatePaths) { + const canonical = canonicalizeEnvironmentCustomImageConfigPath(rawPath); + if (!canonical) { + hasUnresolved = true; + continue; + } + if (seen.has(canonical)) continue; + seen.add(canonical); + canonicalPaths.push(canonical); + } + return { canonicalPaths, hasUnresolved }; +} + +/** + * Builds the capture-time boot-relevant snapshot from the parsed config only. + * Candidate paths are the runtime binding field, the binding unset fields, the + * standard boot-source fields, and every driver identity path. A secret-ref + * overlap or an unresolvable path is recorded in `excludedPaths` with no value. + */ +export function buildEnvironmentCustomImageBootRelevantConfig(input: { + config: SandboxEnvironmentConfig; + binding: EnvironmentCustomImageRuntimeConfigBinding; + templateIdentityPaths?: Iterable; + secretRefExcludePaths?: Iterable; +}): EnvironmentCustomImageBootRelevantConfig { + const config = input.config as Record; + const secretPaths = [...(input.secretRefExcludePaths ?? [])]; + // A null-prototype map is a second guard: a reserved segment assignment can + // never mutate the prototype, so a hostile path cannot vanish from `values`. + const values: Record = Object.create(null); + const excludedPaths = new Set(); + + const { canonicalPaths, hasUnresolved } = environmentCustomImageBootRelevantCandidatePaths({ + binding: input.binding, + templateIdentityPaths: input.templateIdentityPaths, + }); + if (hasUnresolved) { + excludedPaths.add(ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_UNRESOLVED_PATH); + } + for (const canonical of canonicalPaths) { + if (environmentCustomImageConfigPathOverlapsSecret(canonical, secretPaths)) { + excludedPaths.add(canonical); + continue; + } + const value = readConfigValueAtPath(config, canonical); + // An absent field is stored as `null` so that "absent then, absent now" + // compares equal at relink time. + values[canonical] = value === undefined ? null : value; + } + return { + version: ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION, + provider: input.config.provider, + bindingField: input.binding.field, + values, + excludedPaths: [...excludedPaths], + }; +} + +/** + * Reports whether a persisted snapshot no longer matches the current provider + * boot-relevant contract. A provider plugin can change its config binding or + * its identity paths after capture. When it does, a field that is boot-relevant + * now but absent from the snapshot would compare equal by absence and hide real + * drift. The check fails closed on: + * + * - a binding field that differs from the captured `bindingField`; + * - a current identity path the snapshot never covered (neither a value nor an + * excluded path); + * - a current contract that itself carries an unresolvable identity path. + */ +export function environmentCustomImageBootRelevantSnapshotIsStale(input: { + bootRelevantConfig: EnvironmentCustomImageBootRelevantConfig; + currentContract: EnvironmentCustomImageBootRelevantContract; +}): boolean { + const boot = input.bootRelevantConfig; + if (input.currentContract.binding.field !== boot.bindingField) return true; + const { canonicalPaths, hasUnresolved } = environmentCustomImageBootRelevantCandidatePaths( + input.currentContract, + ); + if (hasUnresolved) return true; + const covered = new Set([ + ...Object.keys(boot.values), + ...boot.excludedPaths, + ]); + return canonicalPaths.some((path) => !covered.has(path)); +} + +/** + * Reads and validates the persisted boot-relevant snapshot. Returns `null` for + * a legacy template with no snapshot or for a malformed shape; both classify as + * `unclassified` (fail closed). + */ +export function readEnvironmentCustomImageBootRelevantConfig( + metadata: Record | null | undefined, +): EnvironmentCustomImageBootRelevantConfig | null { + const raw = metadata?.[ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY]; + if (!isRecord(raw)) return null; + if (raw.version !== ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION) return null; + if (typeof raw.provider !== "string" || typeof raw.bindingField !== "string") return null; + if (!isRecord(raw.values)) return null; + if (!Array.isArray(raw.excludedPaths)) return null; + const excludedPaths = raw.excludedPaths.filter( + (entry): entry is string => typeof entry === "string", + ); + return { + version: ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_VERSION, + provider: raw.provider, + bindingField: raw.bindingField, + values: raw.values, + excludedPaths, + }; +} + +/** + * Classifies the drift between the capture-time boot-relevant snapshot and the + * current parsed config. + * + * - `knob_only`: the snapshot is present, no path was excluded, and every value + * still matches. The fingerprint can be re-stamped without confirmation. + * - `boot_source_drift`: a value-bearing path differs. The operator must + * confirm before the re-stamp. + * - `unclassified`: no snapshot, an excluded path, a provider mismatch, or a + * snapshot that no longer matches the current provider contract. The server + * cannot verify the boot source; the operator must confirm (fail closed). + * + * `driftedPaths` carries raw `from`/`to` values only for value-bearing paths + * that passed containment at capture. Excluded paths carry the path name only. + * + * `currentContract` is the current provider boot-relevant contract. When the + * caller cannot resolve the driver, it passes `null`; the server then cannot + * verify the boot source and the result is `unclassified` (fail closed). + */ +export function classifyEnvironmentCustomImageBootRelevantDrift(input: { + bootRelevantConfig: EnvironmentCustomImageBootRelevantConfig | null; + currentConfig: SandboxEnvironmentConfig; + currentContract: EnvironmentCustomImageBootRelevantContract | null; +}): { + classification: EnvironmentCustomImageRelinkClassification; + driftedPaths: EnvironmentCustomImageDriftedPath[]; +} { + const boot = input.bootRelevantConfig; + if (!boot) return { classification: "unclassified", driftedPaths: [] }; + // Without the current provider contract the boot source cannot be verified + // against a possible identity-path change; fail closed. + if (!input.currentContract) return { classification: "unclassified", driftedPaths: [] }; + // A provider change since capture (new binding or new identity path) makes + // the persisted snapshot untrustworthy; fail closed. + if (environmentCustomImageBootRelevantSnapshotIsStale({ + bootRelevantConfig: boot, + currentContract: input.currentContract, + })) { + return { classification: "unclassified", driftedPaths: [] }; + } + const current = input.currentConfig as Record; + + const driftedPaths: EnvironmentCustomImageDriftedPath[] = []; + let hasValueDrift = false; + for (const [path, capturedValue] of Object.entries(boot.values)) { + const currentValue = readConfigValueAtPath(current, path); + if (stableStringify(capturedValue ?? null) !== stableStringify(currentValue ?? null)) { + hasValueDrift = true; + driftedPaths.push({ path, from: capturedValue ?? null, to: currentValue ?? null }); + } + } + for (const path of boot.excludedPaths) { + driftedPaths.push({ path }); + } + const providerMismatch = boot.provider !== input.currentConfig.provider; + if (boot.excludedPaths.length > 0 || providerMismatch) { + return { classification: "unclassified", driftedPaths }; + } + if (hasValueDrift) { + return { classification: "boot_source_drift", driftedPaths }; + } + return { classification: "knob_only", driftedPaths: [] }; +} + +/** + * Removes the server-only boot-relevant snapshot from a template's metadata. + * The snapshot exists to classify a later relink and holds raw boot-source + * config values. It must never reach an API response. The runtime keeps it in + * the persisted row and reads it straight from the row at relink time. + */ +export function stripInternalEnvironmentCustomImageTemplateMetadata( + metadata: Record | null | undefined, +): Record | null { + if (!metadata) return null; + if (!(ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY in metadata)) return metadata; + const rest = { ...metadata }; + delete rest[ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY]; + return rest; +} + export function environmentCustomImageTemplateFromRow(row: TemplateRow): EnvironmentCustomImageTemplate { return { id: row.id, @@ -234,7 +541,9 @@ export function environmentCustomImageTemplateFromRow(row: TemplateRow): Environ capturedAt: row.capturedAt ?? null, lastUsedAt: row.lastUsedAt ?? null, supersededByTemplateId: row.supersededByTemplateId ?? null, - metadata: row.metadata ?? null, + // The boot-relevant snapshot is server-internal; keep it out of every + // template response. The relink path reads it from the persisted row. + metadata: stripInternalEnvironmentCustomImageTemplateMetadata(row.metadata), createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/server/src/services/environment-custom-images.ts b/server/src/services/environment-custom-images.ts index f8a8449f2f9..d481ebfacbf 100644 --- a/server/src/services/environment-custom-images.ts +++ b/server/src/services/environment-custom-images.ts @@ -39,7 +39,11 @@ import { import { environmentService } from "./environments.js"; import { ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY, classifyEnvironmentCustomImageConfigChange, + classifyEnvironmentCustomImageBootRelevantDrift, + buildEnvironmentCustomImageBootRelevantConfig, + readEnvironmentCustomImageBootRelevantConfig, fingerprintEnvironmentSandboxProviderConfig, ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY, defaultEnvironmentCustomImageRuntimeConfigBinding, @@ -47,7 +51,9 @@ import { normalizeEnvironmentCustomImageRuntimeConfigBinding, environmentCustomImageTemplateFromRow, readEnvironmentCustomImageTemplateKind as readTemplateKind, + type EnvironmentCustomImageRelinkClassification, } from "./environment-custom-image-runtime.js"; +import { logActivity } from "./activity-log.js"; import type { PluginWorkerManager } from "./plugin-worker-manager.js"; const ACTIVE_SETUP_STATUSES = ["starting", "waiting_for_user", "capturing"] as const; @@ -230,10 +236,10 @@ function sourceTemplateFromConfig( return { sourceTemplateRef: null, sourceTemplateKind: null }; } -async function resolveActiveTemplate( +async function resolveActiveTemplateRow( db: Db, input: { environmentId: string; provider?: string | null }, -): Promise { +): Promise { const conditions = [ eq(environmentCustomImageTemplates.environmentId, input.environmentId), eq(environmentCustomImageTemplates.status, "active"), @@ -241,12 +247,19 @@ async function resolveActiveTemplate( if (input.provider) { conditions.push(eq(environmentCustomImageTemplates.provider, input.provider)); } - const row = await db + return db .select() .from(environmentCustomImageTemplates) .where(and(...conditions)) .orderBy(desc(environmentCustomImageTemplates.capturedAt), desc(environmentCustomImageTemplates.createdAt)) .then((rows) => rows[0] ?? null); +} + +async function resolveActiveTemplate( + db: Db, + input: { environmentId: string; provider?: string | null }, +): Promise { + const row = await resolveActiveTemplateRow(db, input); return row ? environmentCustomImageTemplateFromRow(row) : null; } @@ -803,11 +816,14 @@ export function environmentCustomImageService( const captured = await callProviderCapture({ session, previousTemplate: currentActive }); const environment = await requireEnvironment(session.environmentId); const parsed = parseEnvironmentDriverConfig(environment); + const captureSecretRefExcludePaths = parsed.driver === "sandbox" + ? [...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider)] + : []; const baseFingerprint = parsed.driver === "sandbox" ? fingerprintEnvironmentSandboxProviderConfig(parsed.config, { excludePaths: [ ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, - ...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider), + ...captureSecretRefExcludePaths, ], }) : null; @@ -821,6 +837,32 @@ export function environmentCustomImageService( templateRefKind: captured.templateKind, templateConfigBinding: provider.driver.templateConfigBinding, }); + // Server-owned boot-relevant snapshot from the parsed config only. It + // lets a later relink decide, without the capture-time config, whether + // the boot source changed. Secret-ref paths carry no value. + const bootRelevantConfig = parsed.driver === "sandbox" + ? buildEnvironmentCustomImageBootRelevantConfig({ + config: parsed.config, + binding: runtimeConfigBinding, + templateIdentityPaths: provider.driver.templateIdentityPaths ?? [], + secretRefExcludePaths: captureSecretRefExcludePaths, + }) + : null; + const baseTemplateMetadata = normalizeProviderMetadata({ + ...(captured.metadata ?? {}), + ...(input.metadata ? { userMetadata: input.metadata } : {}), + ...persistedSetupMetadata(session.metadata), + [ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding, + }) ?? {}; + // The boot-relevant snapshot bypasses the provider-metadata redactor: + // its config field names (for example `apiUrl`) would otherwise be + // redacted by key and the relink comparison could never match. + const templateMetadata = bootRelevantConfig + ? { + ...baseTemplateMetadata, + [ENVIRONMENT_CUSTOM_IMAGE_BOOT_RELEVANT_CONFIG_METADATA_KEY]: bootRelevantConfig, + } + : baseTemplateMetadata; const now = input.now ?? new Date(); const templateRow = await db.transaction(async (tx) => { const templateId = randomUUID(); @@ -851,12 +893,7 @@ export function environmentCustomImageService( createdByUserId: session.startedByUserId, createdByAgentId: session.startedByAgentId, capturedAt: now, - metadata: normalizeProviderMetadata({ - ...(captured.metadata ?? {}), - ...(input.metadata ? { userMetadata: input.metadata } : {}), - ...persistedSetupMetadata(session.metadata), - [ENVIRONMENT_CUSTOM_IMAGE_RUNTIME_CONFIG_BINDING_METADATA_KEY]: runtimeConfigBinding, - }), + metadata: templateMetadata, createdAt: now, updatedAt: now, }) @@ -999,6 +1036,138 @@ export function environmentCustomImageService( }; }, + /** + * Re-stamps the active template's source fingerprint so a detached-but-valid + * template applies again, without a sandbox boot or a new provider snapshot. + * The server classifies the drift between the capture-time boot-relevant + * snapshot and the current config. A `boot_source_drift` or `unclassified` + * result needs the `confirmBootSourceDrift` flag; the client never + * classifies. The re-stamp UPDATE and the activity row run in one + * transaction. Zero updated rows abort with a conflict and write no activity + * row. + */ + relinkActiveTemplate: async (input: { + environmentId: string; + confirmBootSourceDrift?: boolean; + actor: { + actorType: "agent" | "user" | "system" | "plugin"; + actorId: string; + agentId?: string | null; + runId?: string | null; + agentApiKeyId?: string | null; + }; + companyId: string; + now?: Date; + }): Promise<{ + template: EnvironmentCustomImageTemplate; + classification: EnvironmentCustomImageRelinkClassification; + }> => { + const environment = await requireEnvironment(input.environmentId); + const parsed = parseEnvironmentDriverConfig(environment); + if (parsed.driver !== "sandbox") { + throw unprocessable("Environment customImage relink is only supported for sandbox environments."); + } + const activeRow = await resolveActiveTemplateRow(db, { + environmentId: input.environmentId, + provider: parsed.config.provider, + }); + if (!activeRow) throw notFound("Active environment customImage template not found"); + const active = environmentCustomImageTemplateFromRow(activeRow); + + const secretRefExcludePaths = parsed.config.provider === "fake" + ? [] + : [...await resolveSandboxProviderSecretRefPaths(db, parsed.config.provider)]; + // Resolve the current provider contract so the classifier can reject a + // snapshot captured against a different binding or identity-path set. A + // driver that no longer resolves fails closed (null contract). + const resolvedDriver = await resolvePluginSandboxProviderDriverByKey({ + db, + driverKey: active.provider, + }); + const currentContract = resolvedDriver + ? { + binding: templateConfigBindingFromDriver({ + templateRefKind: active.templateKind, + templateConfigBinding: resolvedDriver.driver.templateConfigBinding, + }), + templateIdentityPaths: resolvedDriver.driver.templateIdentityPaths ?? [], + } + : null; + // The persisted snapshot is server-internal; read it from the row, not the + // sanitized template response. + const drift = classifyEnvironmentCustomImageBootRelevantDrift({ + bootRelevantConfig: readEnvironmentCustomImageBootRelevantConfig(activeRow.metadata), + currentConfig: parsed.config, + currentContract, + }); + + const confirmBootSourceDrift = input.confirmBootSourceDrift === true; + if (drift.classification !== "knob_only" && !confirmBootSourceDrift) { + throw conflict( + drift.classification === "boot_source_drift" + ? "The base image changed since this template was captured. Confirm the relink to keep the captured snapshot." + : "The server cannot verify the boot source for this template. Confirm the relink to keep the captured snapshot.", + { + classification: drift.classification, + driftedPaths: drift.driftedPaths, + }, + ); + } + + const nextFingerprint = fingerprintEnvironmentSandboxProviderConfig(parsed.config, { + excludePaths: [ + ...ENVIRONMENT_CUSTOM_IMAGE_CONFIG_FINGERPRINT_EXCLUDED_PATHS, + ...secretRefExcludePaths, + ], + }); + const now = input.now ?? new Date(); + // Activity details carry the sanitized classification and canonical path + // names only. They never carry a fingerprint or a config value. + const activityDetails = { + environmentId: input.environmentId, + templateId: active.id, + provider: active.provider, + classification: drift.classification, + driftedPaths: drift.driftedPaths.map((entry) => entry.path), + confirmBootSourceDrift, + }; + + const row = await db.transaction(async (tx) => { + const updated = await tx + .update(environmentCustomImageTemplates) + .set({ sourceEnvironmentConfigFingerprint: nextFingerprint, updatedAt: now }) + .where(and( + eq(environmentCustomImageTemplates.id, active.id), + eq(environmentCustomImageTemplates.status, "active"), + eq(environmentCustomImageTemplates.environmentId, input.environmentId), + eq(environmentCustomImageTemplates.provider, active.provider), + )) + .returning() + .then((rows) => rows[0] ?? null); + if (!updated) { + throw conflict("Active environment customImage template changed before relink; retry."); + } + await logActivity(tx as unknown as Db, { + companyId: input.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId ?? null, + runId: input.actor.runId ?? null, + agentApiKeyId: input.actor.agentApiKeyId ?? null, + action: "environment.custom_image_template.relinked", + entityType: "environment", + entityId: input.environmentId, + details: activityDetails, + }); + return updated; + }); + + return { + template: environmentCustomImageTemplateFromRow(row), + classification: drift.classification, + }; + }, + rollbackTemplate: async (input: { environmentId: string; now?: Date; diff --git a/ui/src/api/environments.ts b/ui/src/api/environments.ts index 5b1bc6e05f2..46ca219291f 100644 --- a/ui/src/api/environments.ts +++ b/ui/src/api/environments.ts @@ -54,6 +54,32 @@ export interface EnvironmentCustomImageRollbackResult { supersededTemplate: EnvironmentCustomImageTemplate; } +export type EnvironmentCustomImageRelinkClassification = + | "knob_only" + | "boot_source_drift" + | "unclassified"; + +export interface EnvironmentCustomImageRelinkResult { + template: EnvironmentCustomImageTemplate; + classification: EnvironmentCustomImageRelinkClassification; +} + +export interface EnvironmentCustomImageDriftedPath { + path: string; + from?: unknown; + to?: unknown; +} + +/** + * The 409 conflict body a relink returns when the server cannot re-stamp without + * an operator confirmation. `driftedPaths` carries `from`/`to` only for paths + * that passed the secret containment check; excluded paths carry the name only. + */ +export interface EnvironmentCustomImageRelinkConflict { + classification: Exclude; + driftedPaths: EnvironmentCustomImageDriftedPath[]; +} + function companyIdQuery(companyId: string): string { return `companyId=${encodeURIComponent(companyId)}`; } @@ -162,6 +188,15 @@ export const environmentsApi = { `/environments/${environmentId}/custom-image-template/rollback?${companyIdQuery(companyId)}`, {}, ), + relinkCustomImageTemplate: ( + environmentId: string, + companyId: string, + options: { confirmBootSourceDrift?: boolean } = {}, + ) => + api.post( + `/environments/${environmentId}/custom-image-template/relink?${companyIdQuery(companyId)}`, + { confirmBootSourceDrift: options.confirmBootSourceDrift === true }, + ), disableCustomImageTemplate: ( environmentId: string, companyId: string, diff --git a/ui/src/pages/CompanyEnvironments.test.tsx b/ui/src/pages/CompanyEnvironments.test.tsx index cd8a7e52b06..13b27924ebb 100644 --- a/ui/src/pages/CompanyEnvironments.test.tsx +++ b/ui/src/pages/CompanyEnvironments.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TooltipProvider } from "@/components/ui/tooltip"; import { CompanyEnvironments } from "./CompanyEnvironments"; +import { ApiError } from "@/api/client"; const xtermMocks = vi.hoisted(() => { class MockTerminal { @@ -138,6 +139,7 @@ const mockEnvironmentsApi = vi.hoisted(() => ({ finishCustomImageSetupSession: vi.fn(), cancelCustomImageSetupSession: vi.fn(), rollbackCustomImageTemplate: vi.fn(), + relinkCustomImageTemplate: vi.fn(), disableCustomImageTemplate: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ @@ -432,6 +434,10 @@ describe("CompanyEnvironments — test provider button", () => { activeTemplate: createTemplate({ id: "template-previous" }), supersededTemplate: createTemplate({ id: "template-current", status: "superseded" }), }); + mockEnvironmentsApi.relinkCustomImageTemplate.mockResolvedValue({ + template: createTemplate({ id: "template-relinked" }), + classification: "knob_only", + }); mockEnvironmentsApi.disableCustomImageTemplate.mockResolvedValue( createTemplate({ status: "revoked" }), ); @@ -1391,6 +1397,136 @@ describe("CompanyEnvironments — test provider button", () => { }); }); + function setupOutOfSyncTemplatePanel() { + mockEnvironmentsApi.list.mockResolvedValue([ + { id: "env-1", name: "Daytona", driver: "sandbox", description: null, config: { provider: "daytona" } }, + ]); + mockEnvironmentsApi.capabilities.mockResolvedValue({ + adapters: [], + drivers: { local: "supported", ssh: "supported", sandbox: "supported", plugin: "unsupported" }, + sandboxProviders: { + daytona: { + status: "supported", + supportsSavedProbe: true, + supportsUnsavedProbe: true, + supportsRunExecution: true, + supportsReusableLeases: true, + supportsInteractiveSetup: true, + interactiveSetupConnectionTypes: ["ssh"], + supportsTemplateCapture: true, + supportsTemplateDelete: true, + displayName: "Daytona", + }, + }, + }); + mockEnvironmentsApi.customImageTemplate.mockResolvedValue({ + activeTemplate: createTemplate({ id: "template-active" }), + activeTemplateMatchesConfig: false, + activeSession: null, + latestSession: null, + }); + } + + it("relinks an out-of-sync template and names both remedies in the copy", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + setupOutOfSyncTemplatePanel(); + + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + const dialog = getEnvironmentFormPage()!; + expect(dialog.textContent).toContain("relink this image or capture a new one"); + expect(findButton(dialog, "Relink")).toBeTruthy(); + }); + + await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink"))); + await waitForAssertion(() => { + expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledExactlyOnceWith("env-1", "company-1"); + }); + }); + + it("confirms boot-source drift before re-sending the relink with the flag", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + setupOutOfSyncTemplatePanel(); + mockEnvironmentsApi.relinkCustomImageTemplate + .mockRejectedValueOnce(new ApiError("Confirm the relink.", 409, { + error: "Confirm the relink.", + details: { + classification: "boot_source_drift", + driftedPaths: [{ path: "image", from: "fake:base", to: "fake:other" }], + }, + })) + .mockResolvedValueOnce({ + template: createTemplate({ id: "template-relinked" }), + classification: "boot_source_drift", + }); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + + try { + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(findButton(getEnvironmentFormPage()!, "Relink")).toBeTruthy(); + }); + + await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink"))); + await waitForAssertion(() => { + expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenNthCalledWith(1, "env-1", "company-1"); + expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenNthCalledWith( + 2, + "env-1", + "company-1", + { confirmBootSourceDrift: true }, + ); + }); + expect(confirmSpy).toHaveBeenCalledTimes(1); + expect(confirmSpy.mock.calls[0]![0]).toContain("image fake:base -> fake:other"); + } finally { + confirmSpy.mockRestore(); + } + }); + + it("does not re-send the relink when the operator declines the confirmation", async () => { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + setupOutOfSyncTemplatePanel(); + mockEnvironmentsApi.relinkCustomImageTemplate.mockRejectedValueOnce(new ApiError("Cannot verify.", 409, { + error: "Cannot verify.", + details: { classification: "unclassified", driftedPaths: [{ path: "apiUrl" }] }, + })); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false); + + try { + await act(async () => { + root!.render(renderCompanyEnvironments(queryClient)); + }); + await flushReact(); + await act(async () => click(editButtons(container)[0])); + await waitForAssertion(() => { + expect(findButton(getEnvironmentFormPage()!, "Relink")).toBeTruthy(); + }); + + await act(async () => click(findButton(getEnvironmentFormPage()!, "Relink"))); + await waitForAssertion(() => { + expect(confirmSpy).toHaveBeenCalledTimes(1); + }); + expect(confirmSpy.mock.calls[0]![0]).toContain("cannot verify the boot source"); + expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledTimes(1); + expect(mockEnvironmentsApi.relinkCustomImageTemplate).toHaveBeenCalledWith("env-1", "company-1"); + } finally { + confirmSpy.mockRestore(); + } + }); + it("offers the implicit Local option in the default picker by default", async () => { root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); diff --git a/ui/src/pages/CompanyEnvironments.tsx b/ui/src/pages/CompanyEnvironments.tsx index f974caddf86..570d0d0dd7c 100644 --- a/ui/src/pages/CompanyEnvironments.tsx +++ b/ui/src/pages/CompanyEnvironments.tsx @@ -6,7 +6,7 @@ import { useState, } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Check, Lock, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react"; +import { ArrowLeft, Check, Link2, Lock, Play, RefreshCw, RotateCcw, Terminal, Trash2, X } from "lucide-react"; import { FitAddon } from "@xterm/addon-fit"; import { Terminal as XTermTerminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; @@ -21,9 +21,11 @@ import { import { environmentsApi, type EnvironmentCustomImageConnectionPayload, + type EnvironmentCustomImageRelinkConflict, type EnvironmentCustomImageSetupSessionResult, type EnvironmentUpdateResult, } from "@/api/environments"; +import { ApiError } from "@/api/client"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { secretsApi } from "@/api/secrets"; import { Button } from "@/components/ui/button"; @@ -761,6 +763,37 @@ function sessionStatusCopy(status: EnvironmentCustomImageSetupSession["status"]) } } +// The operator declined the drift confirmation prompt. It is not a failure, so +// the relink mutation stays quiet instead of showing an error toast. +class RelinkConfirmationDeclined extends Error { + constructor() { + super("relink confirmation declined"); + this.name = "RelinkConfirmationDeclined"; + } +} + +function formatRelinkDriftValue(value: unknown): string { + if (value === null || value === undefined) return "(none)"; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +// Turns the sanitized 409 drift body into the operator warning. Value-bearing +// drift shows the changed field; an unclassified result warns that the snapshot +// will override the current base image. +function relinkDriftWarning(conflict: EnvironmentCustomImageRelinkConflict): string { + if (conflict.classification === "boot_source_drift") { + const valued = conflict.driftedPaths.find( + (entry) => entry.from !== undefined || entry.to !== undefined, + ); + if (valued) { + return `The base image changed: ${valued.path} ${formatRelinkDriftValue(valued.from)} -> ${formatRelinkDriftValue(valued.to)}.`; + } + return "The base image changed since this image was captured."; + } + return "The server cannot verify the boot source; the snapshot will override the current base image."; +} + function EnvironmentImageTemplatePanel({ environment, companyId, @@ -909,6 +942,50 @@ function EnvironmentImageTemplatePanel({ }, }); + const relinkTemplateMutation = useMutation({ + // The route is called without the flag first. A 409 carries the sanitized + // drift detail; the operator must confirm before the flagged retry. + mutationFn: async () => { + try { + return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId); + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + const conflict = (error.body as { details?: EnvironmentCustomImageRelinkConflict } | null)?.details; + const warning = conflict ? relinkDriftWarning(conflict) : error.message; + if (!window.confirm(`${warning}\n\nRelink this image anyway?`)) { + throw new RelinkConfirmationDeclined(); + } + return await environmentsApi.relinkCustomImageTemplate(environment.id, companyId, { + confirmBootSourceDrift: true, + }); + } + throw error; + } + }, + onSuccess: (result) => { + queryClient.setQueryData(overviewKey, (current: typeof overviewQuery.data) => ({ + activeTemplate: result.template, + activeTemplateMatchesConfig: true, + activeSession: current?.activeSession ?? null, + latestSession: current?.latestSession ?? null, + })); + invalidateOverview(); + pushToast({ + title: "Template relinked", + body: "Runs use the captured image again.", + tone: "success", + }); + }, + onError: (error) => { + if (error instanceof RelinkConfirmationDeclined) return; + pushToast({ + title: "Failed to relink template", + body: error instanceof Error ? error.message : "Relink failed.", + tone: "error", + }); + }, + }); + const disableTemplateMutation = useMutation({ mutationFn: () => environmentsApi.disableCustomImageTemplate(environment.id, companyId), onSuccess: (template) => { @@ -983,6 +1060,7 @@ function EnvironmentImageTemplatePanel({ startSetupMutation.isPending || finishSetupMutation.isPending || cancelSetupMutation.isPending || + relinkTemplateMutation.isPending || rollbackTemplateMutation.isPending || disableTemplateMutation.isPending; @@ -1076,8 +1154,8 @@ function EnvironmentImageTemplatePanel({ data-testid={`custom-image-template-out-of-sync-${environment.id}`} > Not in use — the environment configuration changed since this image was - captured. Runs fall back to the base configuration until you capture a new - image. + captured. Runs fall back to the base configuration until you relink this + image or capture a new one. ) : null} @@ -1091,6 +1169,16 @@ function EnvironmentImageTemplatePanel({ Refresh + + ); +} + export function IssueRow({ issue, issueLinkState, @@ -292,27 +324,7 @@ export function IssueRow({ {(onArchive || desktopTrailing || trailingMeta || externalObjectSummary) ? ( {onArchive ? ( - + ) : null} {externalObjectSummary ? ( diff --git a/ui/src/components/MarkdownCodeBlockStyles.test.ts b/ui/src/components/MarkdownCodeBlockStyles.test.ts index f269e90bf86..13f31b82e47 100644 --- a/ui/src/components/MarkdownCodeBlockStyles.test.ts +++ b/ui/src/components/MarkdownCodeBlockStyles.test.ts @@ -18,8 +18,8 @@ function cssBlock(selector: string): string { return stylesheet.slice(bodyStart + 1, bodyEnd); } -/* The rendered code block used to be pinned to the Catppuccin literals - #1e1e2e / #cdd6f4, in the normal AND the prose-invert variables. A code +/* The rendered code block used to be pinned to fixed Catppuccin literals + in the normal AND the prose-invert variables. A code block therefore stayed dark in light mode. These tests fail if any of those surfaces is pinned to a literal again, rather than riding a token that carries a `.dark` override. */ diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 4e5cb53bb5f..b7445fa50e0 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -4,7 +4,7 @@ import type { ComponentProps } from "react"; import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { Issue } from "@paperclipai/shared"; +import type { Approval, HeartbeatRun, Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CompanyJoinRequest } from "../api/access"; import { @@ -266,6 +266,73 @@ function createJoinRequest( }; } +function createApproval(overrides: Partial = {}): Approval { + return { + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + requestedByAgentId: null, + requestedByUserId: "local-board", + status: "pending", + payload: { name: "New teammate" }, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + ...overrides, + }; +} + +function createFailedRun(overrides: Partial = {}): HeartbeatRun { + return { + id: "run-1", + companyId: "company-1", + agentId: "agent-1", + responsibleUserId: null, + invocationSource: "assignment", + triggerDetail: null, + status: "failed", + error: "boom", + wakeupRequestId: null, + exitCode: null, + signal: null, + usageJson: null, + resultJson: null, + sessionIdBefore: null, + sessionIdAfter: null, + logStore: null, + logRef: null, + logBytes: null, + logSha256: null, + logCompressed: false, + stdoutExcerpt: null, + stderrExcerpt: null, + errorCode: null, + externalRunId: null, + processPid: null, + processGroupId: null, + processStartedAt: null, + lastOutputAt: null, + lastOutputSeq: 0, + lastOutputStream: null, + lastOutputBytes: null, + retryOfRunId: null, + processLossRetryCount: 0, + livenessState: null, + livenessReason: null, + continuationAttempt: 0, + lastUsefulActionAt: null, + nextAction: null, + contextSnapshot: null, + startedAt: new Date("2026-03-11T00:00:00.000Z"), + finishedAt: new Date("2026-03-11T00:01:00.000Z"), + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:01:00.000Z"), + ...overrides, + }; +} + function resetInboxApiMocks() { for (const mock of Object.values(apiMocks)) mock.mockReset(); externalObjectMocks.summaries.clear(); @@ -345,6 +412,49 @@ describe("Inbox toolbar", () => { act(() => root.unmount()); }); + it("keeps archive hover actions and swipe targets on every unread non-task Mine row", async () => { + routerMock.location.pathname = "/inbox/mine"; + localStorage.setItem("paperclip:inbox:group-by", "none"); + apiMocks.approvalsList.mockResolvedValue([createApproval()]); + apiMocks.heartbeatRunsList.mockResolvedValue([createFailedRun()]); + apiMocks.joinRequestsList.mockResolvedValue([createJoinRequest()]); + + 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("Hire Agent: New teammate"); + expect(container.textContent).toContain("Failed run"); + expect(container.textContent).toContain("Jordan Example"); + }); + + const rowFor = (text: string) => + [...container.querySelectorAll("[data-inbox-item]")] + .find((row) => row.textContent?.includes(text)); + + for (const text of ["Hire Agent: New teammate", "Failed run", "Jordan Example"]) { + const row = rowFor(text); + expect(row, `missing inbox row for ${text}`).toBeDefined(); + expect(row?.querySelector('button[aria-label="Mark as read"]')).not.toBeNull(); + const archiveButton = row?.querySelector('button[aria-label="Archive"]'); + expect(archiveButton).not.toBeNull(); + expect(archiveButton?.className).toContain("opacity-0"); + expect(archiveButton?.className).toContain("group-hover:opacity-100"); + expect(row?.querySelector("[data-inbox-row-surface]")).not.toBeNull(); + } + + 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"; @@ -990,51 +1100,7 @@ describe("FailedRunInboxRow", () => { it("suppresses accent hover styling when selected", () => { const root = createRoot(container); - const run = { - id: "run-1", - companyId: "company-1", - agentId: "agent-1", - responsibleUserId: null, - invocationSource: "assignment", - triggerDetail: null, - status: "failed", - error: "boom", - wakeupRequestId: null, - exitCode: null, - signal: null, - usageJson: null, - resultJson: null, - sessionIdBefore: null, - sessionIdAfter: null, - logStore: null, - logRef: null, - logBytes: null, - logSha256: null, - logCompressed: false, - lastOutputAt: null, - lastOutputSeq: 0, - lastOutputStream: null, - lastOutputBytes: null, - errorCode: null, - externalRunId: null, - processPid: null, - processGroupId: null, - processStartedAt: null, - retryOfRunId: null, - processLossRetryCount: 0, - livenessState: null, - livenessReason: null, - continuationAttempt: 0, - lastUsefulActionAt: null, - nextAction: null, - stdoutExcerpt: null, - stderrExcerpt: null, - contextSnapshot: null, - startedAt: new Date("2026-03-11T00:00:00.000Z"), - finishedAt: null, - createdAt: new Date("2026-03-11T00:00:00.000Z"), - updatedAt: new Date("2026-03-11T00:00:00.000Z"), - } as const; + const run = createFailedRun(); act(() => { root.render( diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 4bf93eb0d49..1d2fa37c626 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -79,7 +79,7 @@ import { issueTrailingColumns, } from "../components/IssueColumns"; import { IssueFiltersPopover } from "../components/IssueFiltersPopover"; -import { IssueRow } from "../components/IssueRow"; +import { InboxArchiveButton, IssueRow } from "../components/IssueRow"; import { BlockedInboxView } from "../components/BlockedInboxView"; import { SwipeToArchive } from "../components/SwipeToArchive"; @@ -340,16 +340,6 @@ export function FailedRunInboxRow({ unreadState === "fading" ? "opacity-0" : "opacity-100", )} /> - ) : onArchive ? ( - ) : (
+ {onArchive ? ( + + ) : null} - ) : onArchive ? ( - ) : ( - {showResolutionButtons ? ( + {(onArchive || showResolutionButtons) ? (
- - + {onArchive ? ( + + ) : null} + {showResolutionButtons ? ( + <> + + + + ) : null}
) : null}
@@ -632,16 +622,6 @@ function JoinRequestInboxRow({ unreadState === "fading" ? "opacity-0" : "opacity-100", )} /> - ) : onArchive ? ( - ) : (
+ {onArchive ? ( + + ) : null} + {access.state === "ready" && !access.handoffAvailable ? ( + + Signs in with the snapshot-local credentials captured when this clone was made. + + ) : null} + {access.state === "ready" && access.handoffAvailable ? ( + + Uses a single-use login handoff — no password needed. + + ) : null} +
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + {access.secondaryNotice && SecondaryNoticeIcon ? ( +
+

{access.secondaryNotice.title}

+

{access.secondaryNotice.description}

+ +
+ ) : null} + + + ); +} diff --git a/ui/src/lib/workspace-access-state.test.ts b/ui/src/lib/workspace-access-state.test.ts new file mode 100644 index 00000000000..1ef00cb8933 --- /dev/null +++ b/ui/src/lib/workspace-access-state.test.ts @@ -0,0 +1,335 @@ +import type { WorkspaceOperation, WorkspaceRuntimeService } from "@paperclipai/shared"; +import { describe, expect, it } from "vitest"; +import { + describeWorkspaceReadinessCause, + resolveWorkspaceAccessState, +} from "./workspace-access-state"; + +function runtimeService(overrides: Partial = {}): WorkspaceRuntimeService { + return { + id: "runtime-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: null, + executionWorkspaceId: "ews-1", + issueId: null, + scopeType: "execution_workspace", + scopeId: "ews-1", + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + reuseKey: null, + command: "pnpm dev:once", + cwd: "/srv/worktree", + port: 42013, + url: "https://workspace.example.ts.net:42013/", + provider: "local_process", + providerRef: null, + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: new Date("2026-08-19T00:00:00.000Z"), + startedAt: new Date("2026-08-19T00:00:00.000Z"), + stoppedAt: null, + stopPolicy: null, + healthStatus: "healthy", + createdAt: new Date("2026-08-19T00:00:00.000Z"), + updatedAt: new Date("2026-08-19T00:00:00.000Z"), + ...overrides, + }; +} + +function operation(overrides: Partial = {}): WorkspaceOperation { + return { + id: "op-1", + companyId: "company-1", + executionWorkspaceId: "ews-1", + heartbeatRunId: null, + issueId: null, + phase: "workspace_runtime_provision", + command: null, + cwd: null, + status: "succeeded", + exitCode: 0, + logStore: null, + logRef: null, + logBytes: null, + logSha256: null, + logCompressed: false, + stdoutExcerpt: null, + stderrExcerpt: null, + metadata: null, + startedAt: new Date("2026-08-19T00:00:00.000Z"), + finishedAt: new Date("2026-08-19T00:01:00.000Z"), + createdAt: new Date("2026-08-19T00:00:00.000Z"), + updatedAt: new Date("2026-08-19T00:01:00.000Z"), + ...overrides, + }; +} + +describe("resolveWorkspaceAccessState", () => { + it("is ready with a password-independent open action when a healthy runtime publishes a URL", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService()], + operations: [operation()], + }); + expect(access).toMatchObject({ + state: "ready", + action: { kind: "open", label: "Open workspace" }, + handoffAvailable: true, + }); + expect(access.description).toContain("without a password"); + }); + + it("shows a repair-in-progress state with the current phase and no action", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService()], + operations: [ + operation({ phase: "workspace_repair", status: "running", metadata: { repairPhase: "full_reseed" } }), + ], + }); + expect(access.state).toBe("repairing"); + expect(access.description).toContain("full_reseed"); + expect(access.description).toContain("preserved"); + expect(access.action.kind).toBe("wait"); + }); + + it("shows a failed repair with the failing phase and points at the log", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [], + operations: [ + operation({ phase: "workspace_repair", status: "failed", metadata: { repairPhase: "readiness_validation" } }), + ], + }); + expect(access).toMatchObject({ state: "failed", action: { kind: "view_logs" } }); + expect(access.title).toBe("Repair failed"); + expect(access.description).toContain("readiness_validation"); + expect(access.description).toContain("backup"); + }); + + it("returns to ready after a failed repair when a newer healthy runtime is serving", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:03:00.000Z") })], + operations: [ + operation({ + phase: "workspace_repair", + status: "failed", + metadata: { repairPhase: "managed_restart" }, + finishedAt: new Date("2026-08-19T00:02:00.000Z"), + }), + ], + }); + + expect(access).toMatchObject({ + state: "ready", + action: { kind: "open", label: "Open workspace" }, + secondaryNotice: { + title: "Repair failed", + action: { kind: "view_logs", label: "View repair log" }, + }, + }); + expect(access.secondaryNotice?.description).toContain("managed_restart"); + expect(access.secondaryNotice?.description).toContain("backup"); + }); + + it("returns to ready when a serving runtime has subsequently reported ready", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:01:00.000Z") })], + operations: [ + operation({ + phase: "workspace_repair", + status: "failed", + metadata: { repairPhase: "managed_restart" }, + finishedAt: new Date("2026-08-19T00:02:00.000Z"), + }), + ], + handoffFailure: { + reason: "workspace_not_ready", + readiness: { + state: "ready", + databaseReady: true, + cloneDataReady: true, + authHandoffReady: true, + authHandoffUserId: null, + seedState: "verified", + seedPhase: "complete", + seedMode: "full", + instanceId: "instance-a", + executionWorkspaceId: "ews-1", + companyId: "company-1", + failurePhase: null, + }, + }, + }); + + expect(access).toMatchObject({ state: "ready", action: { kind: "open" } }); + expect(access.secondaryNotice?.action.kind).toBe("view_logs"); + }); + + it("keeps a newer failed repair primary even when an older runtime is healthy", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService({ startedAt: new Date("2026-08-19T00:01:00.000Z") })], + operations: [ + operation({ + phase: "workspace_repair", + status: "failed", + finishedAt: new Date("2026-08-19T00:02:00.000Z"), + }), + ], + }); + + expect(access).toMatchObject({ state: "failed", action: { kind: "view_logs" } }); + expect(access.secondaryNotice).toBeUndefined(); + }); + + it("shows provisioning while the clone is restoring", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [], + operations: [operation({ status: "running" })], + }); + expect(access).toMatchObject({ state: "provisioning", action: { kind: "wait" } }); + expect(access.title).toBe("Provisioning database"); + }); + + it("offers a repair when provisioning failed, naming the seed phase", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [], + operations: [operation({ status: "failed", metadata: { seedFailurePhase: "restore" } })], + }); + expect(access).toMatchObject({ state: "failed", action: { kind: "repair", label: "Repair workspace" } }); + expect(access.description).toContain("restore"); + }); + + it("shows validating, not degraded, while a fresh clone is still being confirmed", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService()], + operations: [operation()], + handoffFailure: { + reason: "workspace_not_ready", + detail: "clone_data_missing", + readiness: { + state: "validating", + databaseReady: true, + cloneDataReady: false, + authHandoffReady: true, + authHandoffUserId: null, + seedState: "unknown", + seedPhase: "legacy_complete_marker", + seedMode: null, + instanceId: "instance-a", + executionWorkspaceId: "ews-1", + companyId: "company-1", + failurePhase: "clone_data_missing", + }, + }, + }); + expect(access).toMatchObject({ state: "validating", action: { kind: "wait" } }); + expect(access.description).toContain("restored no company or issue rows"); + }); + + it("degrades a verified clone whose database regressed and offers one repair", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService()], + operations: [operation()], + handoffFailure: { + reason: "workspace_not_ready", + detail: "database_unreachable", + readiness: { + state: "degraded", + databaseReady: false, + cloneDataReady: false, + authHandoffReady: false, + authHandoffUserId: null, + seedState: "verified", + seedPhase: "complete", + seedMode: "minimal", + instanceId: "instance-a", + executionWorkspaceId: "ews-1", + companyId: "company-1", + failurePhase: "database_unreachable", + }, + }, + }); + expect(access).toMatchObject({ state: "degraded", action: { kind: "repair" } }); + expect(access.description).toContain("isolated database is not answering"); + }); + + it("labels the credential path accurately when no handoff is configured", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService()], + operations: [operation()], + handoffFailure: { reason: "handoff_not_configured" }, + }); + expect(access).toMatchObject({ + state: "ready", + action: { kind: "open" }, + handoffAvailable: false, + }); + expect(access.description).toContain("snapshot-local credentials"); + }); + + it("offers start when nothing is running", () => { + expect( + resolveWorkspaceAccessState({ runtimeServices: [], operations: [] }), + ).toMatchObject({ state: "provisioning", action: { kind: "start", label: "Start workspace" } }); + + expect( + resolveWorkspaceAccessState({ + runtimeServices: [], + operations: [], + handoffFailure: { reason: "runtime_not_running" }, + }), + ).toMatchObject({ state: "provisioning", action: { kind: "start" } }); + }); + + it("refuses to call an unhealthy running runtime ready", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService({ healthStatus: "unhealthy" })], + operations: [operation()], + }); + expect(access).toMatchObject({ state: "degraded", action: { kind: "repair" } }); + }); + + it("treats a running service with no URL as not yet serving", () => { + const access = resolveWorkspaceAccessState({ + runtimeServices: [runtimeService({ url: null })], + operations: [operation()], + }); + expect(access.state).not.toBe("ready"); + }); + + it("handles missing operations and services without throwing", () => { + expect(resolveWorkspaceAccessState({ runtimeServices: null, operations: undefined }).state) + .toBe("provisioning"); + }); +}); + +describe("describeWorkspaceReadinessCause", () => { + it("prefers the recorded readiness phase over the generic reason", () => { + expect( + describeWorkspaceReadinessCause({ + reason: "workspace_not_ready", + readiness: { + state: "degraded", + databaseReady: true, + cloneDataReady: true, + authHandoffReady: false, + authHandoffUserId: null, + seedState: "verified", + seedPhase: "complete", + seedMode: "minimal", + instanceId: "i", + executionWorkspaceId: "e", + companyId: "company-1", + failurePhase: "cloned_membership_missing", + }, + }), + ).toBe("No cloned user has an active company membership."); + }); + + it("falls back to the reason and finally to null", () => { + expect(describeWorkspaceReadinessCause({ reason: "runtime_not_running" })) + .toContain("No healthy runtime service"); + expect(describeWorkspaceReadinessCause({ reason: "something_new" })).toBeNull(); + expect(describeWorkspaceReadinessCause(null)).toBeNull(); + }); +}); diff --git a/ui/src/lib/workspace-access-state.ts b/ui/src/lib/workspace-access-state.ts new file mode 100644 index 00000000000..0747d239d4d --- /dev/null +++ b/ui/src/lib/workspace-access-state.ts @@ -0,0 +1,271 @@ +import type { + WorkspaceOperation, + WorkspaceReadiness, + WorkspaceReadinessState, + WorkspaceRuntimeService, +} from "@paperclipai/shared"; + +/** + * Derives the workspace access state the UI shows (PAP-17572). + * + * The board cannot read a cloned workspace's protected health directly, so state + * comes from three server-side facts it *can* see: the live runtime rows, the + * workspace operation log, and the readiness the control plane reported when it + * last tried to mint a login handoff. + * + * Every state carries one concrete next action. The failure this replaces was a + * generic "Load failed" (or worse, a green badge) that told an operator nothing + * about whether to wait, start, repair, or read a log. + */ + +export type WorkspaceAccessActionKind = + | "open" + | "start" + | "repair" + | "view_logs" + /** Nothing to do but wait for a running operation. */ + | "wait"; + +export type WorkspaceAccessAction = { + kind: WorkspaceAccessActionKind; + label: string; +}; + +export type WorkspaceAccessNotice = { + title: string; + description: string; + action: WorkspaceAccessAction; +}; + +export type WorkspaceAccessState = { + state: WorkspaceReadinessState; + title: string; + description: string; + action: WorkspaceAccessAction; + /** True when a password-independent handoff is the expected way in. */ + handoffAvailable: boolean; + /** A non-blocking historical failure that is still useful to inspect. */ + secondaryNotice?: WorkspaceAccessNotice; +}; + +/** What the control plane said the last time a handoff was requested. */ +export type WorkspaceLoginHandoffFailureInfo = { + reason: string; + detail?: string | null; + readiness?: WorkspaceReadiness | null; +}; + +function latestOperation(operations: WorkspaceOperation[], phase: WorkspaceOperation["phase"]) { + return operations.find((operation) => operation.phase === phase) ?? null; +} + +function describeSeedPhase(readiness: WorkspaceReadiness | null | undefined): string | null { + if (!readiness?.failurePhase && !readiness?.seedPhase) return null; + return readiness.failurePhase ?? readiness.seedPhase ?? null; +} + +function timestampMs(value: Date | string | null | undefined): number | null { + if (!value) return null; + const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; +} + +function failedRepairNotice(repair: WorkspaceOperation): WorkspaceAccessNotice { + const phase = typeof repair.metadata?.repairPhase === "string" ? repair.metadata.repairPhase : null; + return { + title: "Repair failed", + description: phase + ? `The repair stopped during ${phase}. The pre-repair backup was kept.` + : "The repair stopped before the workspace became usable. The pre-repair backup was kept.", + action: { kind: "view_logs", label: "View repair log" }, + }; +} + +const HANDOFF_REASON_COPY: Record = { + handoff_not_configured: + "This instance has no workspace login handoff configured, so opening the board falls back to snapshot-local credentials.", + no_board_identity: + "Your session has no cloned user to sign in as, so opening the board falls back to snapshot-local credentials.", + runtime_not_running: "No healthy runtime service is publishing a URL for this workspace yet.", + runtime_url_unusable: "The runtime row is publishing a URL Paperclip cannot open.", + workspace_not_ready: "The cloned database is not ready to accept a login yet.", +}; + +const READINESS_FAILURE_COPY: Record = { + database_unreachable: "The isolated database is not answering.", + clone_data_missing: "The clone restored no company or issue rows.", + clone_data_unreadable: "The cloned product tables could not be read.", + cloned_membership_missing: "No cloned user has an active company membership.", + cloned_identity_unreadable: "The cloned identity tables could not be read.", + auth_handoff_not_configured: "The workspace was started without a login handoff key.", + seed_manifest_unreadable: "The seed manifest is unreadable, so the restore cannot be trusted.", +}; + +/** + * Human cause for a readiness rejection, preferring the specific recorded phase + * over a generic sentence so the copy names what to fix. + */ +export function describeWorkspaceReadinessCause( + failure: WorkspaceLoginHandoffFailureInfo | null | undefined, +): string | null { + if (!failure) return null; + const phase = describeSeedPhase(failure.readiness); + if (phase && READINESS_FAILURE_COPY[phase]) return READINESS_FAILURE_COPY[phase]; + if (phase) return `Last recorded phase: ${phase}.`; + if (failure.detail && READINESS_FAILURE_COPY[failure.detail]) return READINESS_FAILURE_COPY[failure.detail]; + return HANDOFF_REASON_COPY[failure.reason] ?? null; +} + +export function resolveWorkspaceAccessState(input: { + runtimeServices: WorkspaceRuntimeService[] | null | undefined; + operations: WorkspaceOperation[] | null | undefined; + handoffFailure?: WorkspaceLoginHandoffFailureInfo | null; +}): WorkspaceAccessState { + const operations = input.operations ?? []; + const runtimeServices = input.runtimeServices ?? []; + const repair = latestOperation(operations, "workspace_repair"); + const provision = + latestOperation(operations, "workspace_runtime_provision") + ?? latestOperation(operations, "workspace_provision"); + const failure = input.handoffFailure ?? null; + const cause = describeWorkspaceReadinessCause(failure); + const handoffAvailable = failure?.reason !== "handoff_not_configured" && failure?.reason !== "no_board_identity"; + const servingService = runtimeServices.find( + (service) => service.status === "running" && service.healthStatus === "healthy" && service.url, + ); + const repairFinishedAt = timestampMs(repair?.finishedAt); + const servingServiceStartedAt = timestampMs(servingService?.startedAt); + const readinessConfirmsServing = Boolean(servingService && failure?.readiness?.state === "ready"); + const runtimeStartedAfterRepair = repairFinishedAt !== null + && servingServiceStartedAt !== null + && repairFinishedAt < servingServiceStartedAt; + const repairFailureWasSuperseded = repair?.status === "failed" && Boolean( + servingService + && (readinessConfirmsServing || runtimeStartedAfterRepair), + ); + const secondaryNotice = repair?.status === "failed" && repairFailureWasSuperseded + ? failedRepairNotice(repair) + : undefined; + + // A live repair outranks everything: it is already changing the answer. + if (repair?.status === "running") { + const phase = typeof repair.metadata?.repairPhase === "string" ? repair.metadata.repairPhase : null; + return { + state: "repairing", + title: "Repairing workspace database", + description: phase + ? `Only the isolated database is replaced; the git worktree and your files are preserved. Current phase: ${phase}.` + : "Only the isolated database is replaced; the git worktree and your files are preserved.", + action: { kind: "wait", label: "Repair in progress" }, + handoffAvailable, + }; + } + if (repair?.status === "failed" && !repairFailureWasSuperseded) { + const notice = failedRepairNotice(repair); + return { + state: "failed", + ...notice, + handoffAvailable, + }; + } + + if (provision?.status === "running") { + return { + state: "provisioning", + title: "Provisioning database", + description: "Restoring the isolated database clone for this workspace. This runs once before the first start.", + action: { kind: "wait", label: "Provisioning" }, + handoffAvailable, + }; + } + if (provision?.status === "failed") { + const seedPhase = typeof provision.metadata?.seedFailurePhase === "string" + ? provision.metadata.seedFailurePhase + : null; + return { + state: "failed", + title: "Database provisioning failed", + description: seedPhase + ? `The clone failed during ${seedPhase}. Repairing replaces only the isolated database.` + : "The clone did not finish, so this workspace has no usable database yet.", + action: { kind: "repair", label: "Repair workspace" }, + handoffAvailable, + }; + } + + // Readiness the control plane actually observed beats anything inferred from + // runtime rows, because it is the only signal that looked inside the clone. + const staleNotReadyFailure = failure?.reason === "workspace_not_ready" && readinessConfirmsServing; + if (failure && !staleNotReadyFailure) { + if (failure.reason === "runtime_not_running" && !servingService) { + return { + state: "provisioning", + title: "Workspace is not running", + description: "Start the workspace runtime to publish its board.", + action: { kind: "start", label: "Start workspace" }, + handoffAvailable, + }; + } + if (failure.reason === "workspace_not_ready" || failure.reason === "runtime_url_unusable") { + const readinessState = failure.readiness?.state; + const validating = readinessState === "validating" || readinessState === "provisioning"; + return { + state: validating ? "validating" : "degraded", + title: validating ? "Validating clone" : "Workspace is degraded", + description: [ + cause ?? "The workspace is serving, but its clone did not pass the readiness contract.", + validating ? "Paperclip is still confirming the clone." : "One bounded repair replaces the isolated database.", + ].join(" "), + action: validating + ? { kind: "wait", label: "Validating" } + : { kind: "repair", label: "Repair workspace" }, + handoffAvailable, + }; + } + if (!handoffAvailable) { + return { + state: servingService ? "ready" : "degraded", + title: servingService ? "Ready — snapshot-local sign-in" : "Workspace is degraded", + description: cause ?? "Opening the board will ask for the credentials captured in this snapshot.", + action: servingService + ? { kind: "open", label: "Open workspace" } + : { kind: "start", label: "Start workspace" }, + handoffAvailable: false, + secondaryNotice, + }; + } + } + + if (servingService) { + return { + state: "ready", + title: "Ready", + description: "Opening the workspace signs you in to the cloned board without a password.", + action: { kind: "open", label: "Open workspace" }, + handoffAvailable, + secondaryNotice, + }; + } + + const unhealthyService = runtimeServices.find( + (service) => service.status === "running" && service.healthStatus !== "healthy", + ); + if (unhealthyService) { + return { + state: "degraded", + title: "Workspace is degraded", + description: cause + ?? "The runtime is up but did not report a usable database, so Paperclip will not publish it as ready.", + action: { kind: "repair", label: "Repair workspace" }, + handoffAvailable, + }; + } + + return { + state: "provisioning", + title: "Workspace is not running", + description: "Start the workspace runtime to publish its board.", + action: { kind: "start", label: "Start workspace" }, + handoffAvailable, + }; +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 9fc6cf47bbd..6fea2668d74 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -13,6 +13,7 @@ import { CopyText } from "../components/CopyText"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder"; import { agentsApi } from "../api/agents"; +import { ApiError } from "../api/client"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { heartbeatsApi } from "../api/heartbeats"; import { issuesApi } from "../api/issues"; @@ -35,12 +36,17 @@ import { type WorkspaceRuntimeControlRequest, } from "../components/WorkspaceRuntimeControls"; import { WorkspaceServiceControlBar } from "../components/WorkspaceServiceControlBar"; +import { WorkspaceAccessCard } from "../components/WorkspaceAccessCard"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { useToastActions } from "../context/ToastContext"; import { collectLiveIssueIds } from "../lib/liveIssueIds"; import { queryKeys } from "../lib/queryKeys"; import { cn, formatDateTime, issueUrl, projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; +import { + resolveWorkspaceAccessState, + type WorkspaceLoginHandoffFailureInfo, +} from "../lib/workspace-access-state"; import { getWorkspaceSpecificRoutineVariableNames, routineHasWorkspaceSpecificVariables, @@ -412,6 +418,27 @@ export function resolveRuntimeProvisionStatus(input: { return configured ? { kind: "deferred" } : { kind: "eager" }; } +/** + * Read the structured refusal the login-handoff endpoint returns. + * + * The server keeps a machine `reason` (and, where it probed, the workspace's own + * readiness) on the error body so the UI can name the cause instead of showing a + * bare HTTP failure. Anything else is a genuine transport error. + */ +export function readWorkspaceHandoffFailure(error: unknown): WorkspaceLoginHandoffFailureInfo | null { + if (!(error instanceof ApiError)) return null; + const body = error.body as + | { reason?: unknown; detail?: unknown; readiness?: unknown } + | null + | undefined; + if (!body || typeof body.reason !== "string") return null; + return { + reason: body.reason, + detail: typeof body.detail === "string" ? body.detail : null, + readiness: (body.readiness as WorkspaceLoginHandoffFailureInfo["readiness"]) ?? null, + }; +} + function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -779,6 +806,8 @@ export function ExecutionWorkspaceDetail() { const [errorMessage, setErrorMessage] = useState(null); const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(null); + const [handoffFailure, setHandoffFailure] = useState(null); + const [handoffErrorMessage, setHandoffErrorMessage] = useState(null); const [pendingRuntimeActions, setPendingRuntimeActions] = useState([]); const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null; const pluginTabFromSearch = useMemo(() => { @@ -968,6 +997,67 @@ export function ExecutionWorkspaceDetail() { }, }); + /** + * Password-independent workspace entry (PAP-17572). + * + * The server answers with a ticket-bearing URL, and the workspace answers *that* + * with a redirect — which is what keeps the ticket out of session history. + * + * The target tab is opened synchronously on click and only pointed at the URL + * once the ticket arrives. Opening it after the request resolves would be a + * popup the browser did not attribute to the click, and Safari and Firefox + * block exactly that. If the tab could not be opened anyway, fall back to + * navigating this one rather than silently doing nothing. + */ + const openWorkspace = useMutation({ + mutationFn: async () => { + const target = window.open("about:blank", "_blank", "noopener,noreferrer"); + try { + return { ticket: await executionWorkspacesApi.requestLoginHandoff(workspace!.id), target }; + } catch (error) { + target?.close(); + throw error; + } + }, + onSuccess: ({ ticket, target }) => { + setHandoffFailure(null); + setHandoffErrorMessage(null); + if (target && !target.closed) target.location.replace(ticket.url); + else window.location.assign(ticket.url); + }, + onError: async (error) => { + // A structured refusal is rendered as workspace state by the access card, + // so only an unrecognized transport error needs its own message line. + const failure = readWorkspaceHandoffFailure(error); + setHandoffFailure(failure); + setHandoffErrorMessage( + failure ? null : error instanceof Error ? error.message : "Failed to open the workspace.", + ); + // The refusal reason often comes from an operation that has since advanced, + // so refresh the log the access card derives its state from. + await queryClient.invalidateQueries({ + queryKey: queryKeys.executionWorkspaces.workspaceOperations(workspace!.id), + }); + }, + }); + const repairWorkspace = useMutation({ + mutationFn: () => executionWorkspacesApi.repair(workspace!.id), + onSuccess: (result) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(result.workspace.id), result.workspace); + queryClient.invalidateQueries({ + queryKey: queryKeys.executionWorkspaces.workspaceOperations(result.workspace.id), + }); + setHandoffFailure(null); + setHandoffErrorMessage(null); + setRuntimeActionErrorMessage(null); + setRuntimeActionMessage("Workspace database repaired."); + }, + onError: (error) => { + setRuntimeActionMessage(null); + setRuntimeActionErrorMessage(error instanceof Error ? error.message : "Failed to repair the workspace."); + }, + }); + if (workspaceQuery.isLoading) return

Loading workspace…

; if (workspaceQuery.error) { return ( @@ -992,6 +1082,11 @@ export function ExecutionWorkspaceDetail() { runtimeServices: workspace.runtimeServices ?? [], pendingRequests: pendingRuntimeActions, }); + const workspaceAccess = resolveWorkspaceAccessState({ + runtimeServices: workspace.runtimeServices ?? [], + operations: workspaceOperationsQuery.data, + handoffFailure, + }); const pluginSlotContext = { companyId: workspace.companyId, @@ -1062,6 +1157,20 @@ export function ExecutionWorkspaceDetail() { {runtimeActionErrorMessage ?

{runtimeActionErrorMessage}

: null} {!runtimeActionErrorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} + openWorkspace.mutate()} + onStart={() => { + runRuntimeControlRequests( + resolveWorkspaceServiceControlRequests(runtimeControlSections, "start", null), + ); + }} + onRepair={() => repairWorkspace.mutate()} + onViewLogs={() => handleTabChange("runtime_logs")} + errorMessage={handoffErrorMessage} + /> + Date: Wed, 19 Aug 2026 00:43:57 -0700 Subject: [PATCH 19/39] build(deps): bump open from 11.0.0 to 11.0.1 (#11517) Bumps [open](https://github.com/sindresorhus/open) from 11.0.0 to 11.0.1.
Release notes

Sourced from open's releases.

v11.0.1

  • Fix WSL failure when the working directory is unreachable from Windows 6ac3fe4

https://github.com/sindresorhus/open/compare/v11.0.0...v11.0.1

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 190 ++++++++++++++++++++++++++++++++++++-------- server/package.json | 2 +- 2 files changed, 156 insertions(+), 36 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdb04507d00..663030b022e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,7 +724,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.2.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) packages/teams-catalog: devDependencies: @@ -822,8 +822,8 @@ importers: specifier: ^2.2.0 version: 2.2.0 open: - specifier: ^11.0.0 - version: 11.0.0 + specifier: ^11.0.1 + version: 11.0.1 pino: specifier: ^9.6.0 version: 9.14.0 @@ -5197,6 +5197,9 @@ packages: '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/mocker@4.1.10': resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: @@ -5208,30 +5211,56 @@ packages: vite: optional: true + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} '@vitest/pretty-format@4.1.10': resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@4.1.10': resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/snapshot@4.1.10': resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} '@vitest/spy@4.1.10': resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -6020,10 +6049,6 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} - engines: {node: '>=18'} - default-browser@5.5.1: resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} @@ -6274,6 +6299,9 @@ packages: es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -7420,10 +7448,6 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - open@11.0.0: - resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} - engines: {node: '>=20'} - open@11.0.1: resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==} engines: {node: '>=20'} @@ -8241,6 +8265,10 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tinyspy@4.0.4: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} @@ -8584,6 +8612,47 @@ packages: jsdom: optional: true + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -8658,10 +8727,6 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - wsl-utils@0.3.1: - resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} - engines: {node: '>=20'} - wsl-utils@1.0.0: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} @@ -12952,6 +13017,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': dependencies: '@vitest/spy': 4.1.10 @@ -12976,6 +13050,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + '@vitest/mocker@4.1.11(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -12984,11 +13066,20 @@ snapshots: dependencies: tinyrainbow: 3.1.0 + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + '@vitest/runner@4.1.10': dependencies: '@vitest/utils': 4.1.10 pathe: 2.0.3 + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + '@vitest/snapshot@4.1.10': dependencies: '@vitest/pretty-format': 4.1.10 @@ -12996,12 +13087,21 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 '@vitest/spy@4.1.10': {} + '@vitest/spy@4.1.11': {} + '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 @@ -13014,6 +13114,12 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@webcontainer/env@1.1.1': {} '@xterm/addon-fit@0.11.0': {} @@ -13773,11 +13879,6 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.0: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.1 - default-browser@5.5.1: dependencies: bundle-name: 4.1.0 @@ -13934,6 +14035,8 @@ snapshots: es-module-lexer@2.3.1: {} + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -15574,20 +15677,11 @@ snapshots: open@10.2.0: dependencies: - default-browser: 5.5.0 + default-browser: 5.5.1 define-lazy-prop: 3.0.0 is-inside-container: 1.0.0 wsl-utils: 0.1.0 - open@11.0.0: - dependencies: - default-browser: 5.5.0 - define-lazy-prop: 3.0.0 - is-in-ssh: 1.0.0 - is-inside-container: 1.0.0 - powershell-utils: 0.1.0 - wsl-utils: 0.3.1 - open@11.0.1: dependencies: default-browser: 5.5.1 @@ -16695,6 +16789,8 @@ snapshots: tinyrainbow@3.1.0: {} + tinyrainbow@3.1.1: {} + tinyspy@4.0.4: {} tldts-core@7.0.26: {} @@ -17043,6 +17139,35 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 22.20.1 + jsdom: 28.1.0(@noble/hashes@2.3.0) + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@9.0.1: {} @@ -17103,11 +17228,6 @@ snapshots: dependencies: is-wsl: 3.1.1 - wsl-utils@0.3.1: - dependencies: - is-wsl: 3.1.1 - powershell-utils: 0.1.0 - wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1 diff --git a/server/package.json b/server/package.json index d011fd4da78..8410e8e088e 100644 --- a/server/package.json +++ b/server/package.json @@ -72,7 +72,7 @@ "express": "^5.1.0", "jsdom": "^28.1.0", "multer": "^2.2.0", - "open": "^11.0.0", + "open": "^11.0.1", "pino": "^9.6.0", "pino-http": "^10.4.0", "pino-pretty": "^13.1.3", From 59c4b5a9c7ec3411628633fb30986c337d77d569 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:45:59 -0700 Subject: [PATCH 20/39] build(deps): bump @base-ui/react from 1.6.0 to 1.7.0 (#11518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@base-ui/react](https://github.com/mui/base-ui/tree/HEAD/packages/react) from 1.6.0 to 1.7.0.
Release notes

Sourced from @​base-ui/react's releases.

v1.7.0

General changes

Accordion

Alert Dialog

  • Prevent <AlertDialog.Root> from reopening after remounting with a reused handle (#5109) by @​michaldudak

Autocomplete

Avatar

Button

Checkbox

... (truncated)

Changelog

Sourced from @​base-ui/react's changelog.

v1.7.0

Aug 4, 2026

General changes

Accordion

Alert Dialog

  • Prevent <AlertDialog.Root> from reopening after remounting with a reused handle (#5109) by @​michaldudak

Autocomplete

Avatar

Button

... (truncated)

Commits
  • 254f474 [release] v1.7.0 (#5404)
  • 9222cda [meter] Format clamped values (#5409)
  • becc19a [combobox][autocomplete][select] Fix listbox separator semantics (#5399)
  • 7cc0eef [drawer] Fix click-only outside dismissal after swipe (#5392)
  • 1a2ca3c [all components] Fix canceled exit unmount (#5401)
  • 166e8ac [popups] Fix quadratic dev-mode trigger registration check (#5400)
  • 071e892 [all components] Avoid unused popup handle attachments (#5394)
  • cbc87d1 [select] Stabilize scroll arrow cleanup test (#5402)
  • 3b5715c [all components] Fix popup handle lifecycle regressions (#5387)
  • 54cfcc1 [typescript] Preserve published internals types (#5386)
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@base-ui/react&package-manager=npm_and_yarn&previous-version=1.6.0&new-version=1.7.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 18 +++++++++--------- ui/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 663030b022e..0bfccd9a628 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -895,8 +895,8 @@ importers: specifier: 0.14.14 version: 0.14.14(@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)(use-sync-external-store@1.6.0(react@19.2.7)) '@base-ui/react': - specifier: ^1.6.0 - version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^1.7.0 + version: 1.7.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1428,8 +1428,8 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.6.0': - resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + '@base-ui/react@1.7.0': + resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -1445,8 +1445,8 @@ packages: date-fns: optional: true - '@base-ui/utils@0.3.1': - resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + '@base-ui/utils@0.3.2': + resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^19.2.7 @@ -9325,10 +9325,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 - '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@base-ui/utils': 0.3.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@floating-ui/utils': 0.2.12 react: 19.2.7 @@ -9337,7 +9337,7 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@base-ui/utils@0.3.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@babel/runtime': 7.29.7 '@floating-ui/utils': 0.2.12 diff --git a/ui/package.json b/ui/package.json index b1b5806c591..f7b72eb55e8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@assistant-ui/react": "0.14.14", - "@base-ui/react": "^1.6.0", + "@base-ui/react": "^1.7.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", From 536d5880c580f47628bc48f92c1a5750fbd7dd62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:48:17 -0700 Subject: [PATCH 21/39] build(deps): bump @cursor/sdk from 1.0.24 to 1.0.28 (#11520) Bumps [@cursor/sdk](https://github.com/cursor/cursor) from 1.0.24 to 1.0.28.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@cursor/sdk&package-manager=npm_and_yarn&previous-version=1.0.24&new-version=1.0.28)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/adapters/cursor-cloud/package.json | 2 +- pnpm-lock.yaml | 50 ++++++++++----------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/adapters/cursor-cloud/package.json b/packages/adapters/cursor-cloud/package.json index 521f8c18f6f..6082595064b 100644 --- a/packages/adapters/cursor-cloud/package.json +++ b/packages/adapters/cursor-cloud/package.json @@ -50,7 +50,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@cursor/sdk": "^1.0.24", + "@cursor/sdk": "^1.0.28", "@paperclipai/adapter-utils": "workspace:*", "picocolors": "^1.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0bfccd9a628..8d891504e47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,8 +177,8 @@ importers: packages/adapters/cursor-cloud: dependencies: '@cursor/sdk': - specifier: ^1.0.24 - version: 1.0.24 + specifier: ^1.0.28 + version: 1.0.28 '@paperclipai/adapter-utils': specifier: workspace:* version: link:../../adapter-utils @@ -1732,38 +1732,38 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@cursor/sdk-darwin-arm64@1.0.24': - resolution: {integrity: sha512-0RIjcm2CFLuu0N/GnQ3G+8UGIQKv9jEgVkLr2gGjVoEN5UfwA6G3eLO/U6SfnMdFNkftJ9j/efcCHuS/OFPuNA==} + '@cursor/sdk-darwin-arm64@1.0.28': + resolution: {integrity: sha512-P9k17dHzyYirjvMIkdxGtIWFEpkC1lB0TSbNE3AEusr5UwYTHFt13+MK5j9GK8Tg3r9ncg4IeSDQgx7HvA2VAw==} cpu: [arm64] os: [darwin] hasBin: true - '@cursor/sdk-darwin-x64@1.0.24': - resolution: {integrity: sha512-BmD1hbV0SCIRv9FGIhoM4XgCbWdcVGpjiPXUiIlGaYDM9ZXpmit6x9tx2iSGUIIc7ytpIYsQaSBEUsSQsNl7wQ==} + '@cursor/sdk-darwin-x64@1.0.28': + resolution: {integrity: sha512-SFamp6b3a5/Og4RIYpxivA9TNqSam8+yeDwdu4+iV9GwZygtw2y1r8Qz2r7VpDj40gou2UP/p8Nu54nAdAl4Mg==} cpu: [x64] os: [darwin] hasBin: true - '@cursor/sdk-linux-arm64@1.0.24': - resolution: {integrity: sha512-zCZcHwKPopqeXEsja2EmsV6En3hAhd3alnCfKbiYZdE9XQAaTza0XFji7a4uRjKGitlQ0JDIYHRIJg/2nvTkrQ==} + '@cursor/sdk-linux-arm64@1.0.28': + resolution: {integrity: sha512-J0Pp2vZLApRm4+j35zNFaMkAuI7AoTzY7bt1UdRC/NSAcJsh74soCf/HqB59Ps94HaznF7PYl1ft6ltnn4to4g==} cpu: [arm64] os: [linux] hasBin: true - '@cursor/sdk-linux-x64@1.0.24': - resolution: {integrity: sha512-pK80OM4CSB0G8ModhRhb85+FYEROmK8nad6Lj8LDZngQ1ZgJi/nrdgUuHykOtza7T00D14x9Q2l0pwf/ybXxog==} + '@cursor/sdk-linux-x64@1.0.28': + resolution: {integrity: sha512-YFBA2syvFJZE15d1MBtgqJJCuYGbZEU7GcE8EaGDU3A6iJguZg7PB97c3ux2qN5kJtdk0+EveevrIi2UJsr5Ag==} cpu: [x64] os: [linux] hasBin: true - '@cursor/sdk-win32-x64@1.0.24': - resolution: {integrity: sha512-vU4ossYSuw0adCK0ssZvwCeT6dmdBR29EMdHrB9G89SzIJ9xpodkKKjDL0aN5dEwy7wOKyUJIjprfw/A4U8F5A==} + '@cursor/sdk-win32-x64@1.0.28': + resolution: {integrity: sha512-+9G30RcR+cDkQxiyFBfrNWSBkZJc19G9L7+bTl9PdiBI7FPCu1vCCdP4BBvntdCkFS9LwEX3iJyWJqEreFjNQg==} cpu: [x64] os: [win32] hasBin: true - '@cursor/sdk@1.0.24': - resolution: {integrity: sha512-pHoVRSiyBMTFBMjhAihvzXbiJSFNs63dULE+11jzCX/s1a7/I7Zp0cC4jWRw1ykckEPcho8QiEEgSR3qBRmDBw==} + '@cursor/sdk@1.0.28': + resolution: {integrity: sha512-bO7Ld00xXV5kFB9WDeBiyADgon3r/BQXc9qZ4sHlXX9ZQpzIkTfHrYU7g278pcHTuz+l/vPcPcc7JByeRWHeqA==} engines: {node: '>=22.13'} '@dnd-kit/accessibility@3.1.1': @@ -9796,22 +9796,22 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@cursor/sdk-darwin-arm64@1.0.24': + '@cursor/sdk-darwin-arm64@1.0.28': optional: true - '@cursor/sdk-darwin-x64@1.0.24': + '@cursor/sdk-darwin-x64@1.0.28': optional: true - '@cursor/sdk-linux-arm64@1.0.24': + '@cursor/sdk-linux-arm64@1.0.28': optional: true - '@cursor/sdk-linux-x64@1.0.24': + '@cursor/sdk-linux-x64@1.0.28': optional: true - '@cursor/sdk-win32-x64@1.0.24': + '@cursor/sdk-win32-x64@1.0.28': optional: true - '@cursor/sdk@1.0.24': + '@cursor/sdk@1.0.28': dependencies: '@bufbuild/protobuf': 1.10.0 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) @@ -9820,11 +9820,11 @@ snapshots: '@statsig/js-client': 3.31.0 zod: 3.25.76 optionalDependencies: - '@cursor/sdk-darwin-arm64': 1.0.24 - '@cursor/sdk-darwin-x64': 1.0.24 - '@cursor/sdk-linux-arm64': 1.0.24 - '@cursor/sdk-linux-x64': 1.0.24 - '@cursor/sdk-win32-x64': 1.0.24 + '@cursor/sdk-darwin-arm64': 1.0.28 + '@cursor/sdk-darwin-x64': 1.0.28 + '@cursor/sdk-linux-arm64': 1.0.28 + '@cursor/sdk-linux-x64': 1.0.28 + '@cursor/sdk-win32-x64': 1.0.28 '@dnd-kit/accessibility@3.1.1(react@19.2.7)': dependencies: From 51a843e1350b0c720aaa6d66c5de8fb8a234b947 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:53:15 -0500 Subject: [PATCH 22/39] fix(cli): accept renumbered migration journal order (#11684) 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 > - Worktree provisioning clones a source database into an isolated workspace > - Source validation must accept a migration journal that matches a prefix of the checkout journal > - Long-lived instances can apply migrations in a different order after migration files are renumbered > - The validator compared application order with filename order and rejected a valid source > - This pull request compares the resolved migration names as a set and records the checkout-prefix revision > - The benefit is that valid renumbered migration histories can pass source validation without allowing divergent histories ## Linked Issues or Issue Description **What happened?** Worktree seed source validation compared applied migrations in database application order with available migration files in filename order. A current source with the same migration set failed with `Migration journal is not a prefix` after migration files were renumbered. **Expected behavior** Source validation must accept a source when its resolved applied migration set equals a prefix of the checkout migration files. It must still reject a source that contains a resolved migration outside that prefix. **Steps to reproduce** 1. Apply migrations before a migration-file renumber operation. 2. Update the checkout so the same migration files have a different filename order. 3. Run worktree seed source validation against the long-lived source. 4. Observe that positional comparison rejects the source even though the sets are equal. **Paperclip version or commit** The bug reproduces on the master-equivalent worktree-seeding implementation before this commit. **Deployment mode** Local dev with embedded PostgreSQL. ## What Changed - Compare resolved applied migration names with the expected checkout prefix as an order-independent set. - Derive the reported source revision from the checkout prefix instead of database application order. - Add unit and embedded-PostgreSQL regressions for shuffled application order, stale unresolved rows, lagging sources, and true divergence. ## Verification - `pnpm exec vitest run cli/src/__tests__/worktree.test.ts` — 54 tests passed. - `pnpm --filter paperclipai typecheck` — passed. - The focused suite includes the real embedded-PostgreSQL seed path. ## Risks - Low risk. The change is limited to source migration-prefix validation. - The validator still rejects missing or unknown resolved migrations. - Duplicate resolved names remain set-equivalent by design. Raw stale journal rows remain tolerated. - Existing documentation already specifies order-independent checkout-prefix behavior, so no documentation change is required. > 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`. The Codex runtime manages the context window. The model used reasoning, shell tools, 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 --- cli/src/__tests__/worktree.test.ts | 68 +++++++++++++++++++++++++++--- cli/src/commands/worktree.ts | 14 ++---- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ba8d3339c3c..b4834dd5121 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -488,14 +488,34 @@ describe("worktree helpers", () => { expect(full.nullifyColumns).toEqual({}); }); - it("rejects a source migration journal that is ahead of the code journal", () => { + it("rejects a source migration journal that diverges from the code journal", () => { expect(() => resolveWorktreeSeedMigrationRevision({ status: "upToDate", tableCount: 1, availableMigrations: ["0001_initial.sql", "0002_current.sql"], - appliedMigrations: ["0001_initial.sql", "0002_current.sql"], + appliedMigrations: ["0001_initial.sql", "0003_unknown.sql"], journalEntryCount: 3, - }, "sourcePrefix")).toThrow("Migration journal is ahead of this Paperclip checkout"); + }, "sourcePrefix")).toThrow("Migration journal is not a prefix of this Paperclip checkout"); + }); + + it("accepts a current source whose migration application order differs from filename order", () => { + expect(resolveWorktreeSeedMigrationRevision({ + status: "upToDate", + tableCount: 1, + availableMigrations: [ + "0001_initial.sql", + "0002_renumbered.sql", + "0003_applied_earlier.sql", + "0004_current.sql", + ], + appliedMigrations: [ + "0001_initial.sql", + "0003_applied_earlier.sql", + "0002_renumbered.sql", + "0004_current.sql", + ], + journalEntryCount: 6, + }, "upToDate")).toBe("0004_current.sql"); }); it("accepts a source migration journal that is multiple revisions behind", () => { @@ -508,9 +528,9 @@ describe("worktree helpers", () => { "0003_pending.sql", "0004_pending.sql", ], - appliedMigrations: ["0001_initial.sql", "0002_applied.sql"], + appliedMigrations: ["0002_applied.sql", "0001_initial.sql"], pendingMigrations: ["0003_pending.sql", "0004_pending.sql"], - journalEntryCount: 2, + journalEntryCount: 3, reason: "pending-migrations", }, "sourcePrefix")).toBe("0002_applied.sql"); }); @@ -1244,7 +1264,7 @@ describe("worktree helpers", () => { }); itEmbeddedPostgres( - "seeds a source whose migration journal is behind the code journal", + "seeds a lagging source whose migration application order differs from filename order", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed"); @@ -1264,7 +1284,30 @@ describe("worktree helpers", () => { DELETE FROM "drizzle"."__drizzle_migrations" WHERE "id" = ( SELECT max("id") FROM "drizzle"."__drizzle_migrations" + ); + + WITH pair AS ( + SELECT + array_agg("id" ORDER BY "id" DESC) AS ids, + array_agg("hash" ORDER BY "id" DESC) AS hashes + FROM ( + SELECT "id", "hash" + FROM "drizzle"."__drizzle_migrations" + ORDER BY "id" DESC + LIMIT 2 + ) latest ) + UPDATE "drizzle"."__drizzle_migrations" migrations + SET "hash" = CASE + WHEN migrations."id" = pair.ids[1] THEN pair.hashes[2] + WHEN migrations."id" = pair.ids[2] THEN pair.hashes[1] + ELSE migrations."hash" + END + FROM pair + WHERE migrations."id" IN (pair.ids[1], pair.ids[2]); + + INSERT INTO "drizzle"."__drizzle_migrations" ("hash", "created_at") + VALUES ('stale-unresolvable-migration-hash', 0) `); await sourceDbClient.$client.end({ timeout: 5 }); const laggingMigrationState = await inspectMigrations(sourceDb.connectionString); @@ -1273,7 +1316,18 @@ describe("worktree helpers", () => { throw new Error("Expected the source migration journal to lag the code journal"); } expect(laggingMigrationState.pendingMigrations).toHaveLength(1); - const sourceMigrationRevision = laggingMigrationState.appliedMigrations.at(-1); + const expectedAppliedPrefix = laggingMigrationState.availableMigrations.slice( + 0, + laggingMigrationState.appliedMigrations.length, + ); + expect(laggingMigrationState.appliedMigrations).not.toEqual(expectedAppliedPrefix); + expect([...laggingMigrationState.appliedMigrations].sort()).toEqual( + [...expectedAppliedPrefix].sort(), + ); + expect(laggingMigrationState.journalEntryCount).toBeGreaterThan( + laggingMigrationState.appliedMigrations.length, + ); + const sourceMigrationRevision = expectedAppliedPrefix.at(-1); expect(sourceMigrationRevision).toBeTruthy(); fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index be6926c220f..d31dabaefcb 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -1416,20 +1416,14 @@ export function resolveWorktreeSeedMigrationRevision( migrationState: Awaited>, requirement: "sourcePrefix" | "upToDate", ): string { - if (migrationState.journalEntryCount > migrationState.availableMigrations.length) { - throw new Error( - `Migration journal is ahead of this Paperclip checkout (${migrationState.journalEntryCount} applied migration(s), ${migrationState.availableMigrations.length} available).`, - ); - } - const expectedAppliedPrefix = migrationState.availableMigrations.slice( 0, migrationState.appliedMigrations.length, ); + const appliedMigrationNames = new Set(migrationState.appliedMigrations); if ( - migrationState.appliedMigrations.some( - (migration, index) => migration !== expectedAppliedPrefix[index], - ) + appliedMigrationNames.size !== expectedAppliedPrefix.length || + expectedAppliedPrefix.some((migration) => !appliedMigrationNames.has(migration)) ) { throw new Error("Migration journal is not a prefix of this Paperclip checkout's migration journal."); } @@ -1440,7 +1434,7 @@ export function resolveWorktreeSeedMigrationRevision( ); } - const migrationRevision = migrationState.appliedMigrations.at(-1); + const migrationRevision = expectedAppliedPrefix.at(-1); if (!migrationRevision) { throw new Error("Migration journal has no applied revision."); } From e1df4c6068fea684a1e9714ebd64bce95f3db19a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:41:29 -0500 Subject: [PATCH 23/39] fix(workspaces): keep deferred seed databases reliable (#11706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip manages agent work in isolated execution workspaces. > - A workspace depends on a valid database seed before it can run. > - Deferred seed failures were hidden behind a successful provision status. > - The seed restore also had two possible owners for the embedded PostgreSQL process. > - That allowed the target database to stop while the restore was still running. > - This pull request makes seed failures visible and gives the seed process sole lifecycle ownership. > - The benefit is that workspace provisioning reports the real result and does not stop its own target database. ## Linked Issues or Issue Description Related: #11684 **What happened?** Initial worktree provisioning could report success before its deferred database seed completed. The seed restore could also reuse a target embedded PostgreSQL process with another shutdown owner. This could stop the target database during the restore. **Expected behavior** Workspace status must show a failed deferred seed as a failure. The seed restore must own the target embedded PostgreSQL process until restore, migration, and validation finish. **Steps to reproduce** 1. Provision a worktree with deferred database seeding. 2. Make the seed manifest end in a failed state while the command exits with code 0. 3. Observe that the provision status remains successful on `master`. 4. Start a seed restore against an already-running target embedded PostgreSQL process. 5. Observe that another lifecycle owner can stop the target during restore. **Paperclip version or commit** `51a843e135` **Deployment mode** Local dev with execution workspaces and embedded PostgreSQL. ## What Changed - Add a first-class `workspace_seed` operation for deferred database seeds. - Require terminal, verified seed evidence before the seed operation succeeds. - Surface the seed phase and failure metadata in workspace status and UI state. - Give the seed process exclusive lifecycle ownership of the target embedded PostgreSQL process. - Suppress imported embedded-Postgres exit hooks without removing existing host listeners. - Record a credential-safe shutdown diagnostic in failed seed manifests. ## Verification - The original deferred-seed commit passed 4 server tests, 24 workspace-status UI tests, shared/server/UI typechecks, and the UI token gate. - The original PostgreSQL-lifecycle commit passed 3 lifecycle tests, 3 ownership/diagnostic tests, 1 real embedded-Postgres seed integration, and the affected package typechecks. - No local tests were rerun after the clean cherry-pick because the operator requested the shortest landing path. - Review the automatic PR checks for the clean `origin/master` replay. ## Risks - A live target database now causes an early error instead of being reused. The error includes recovery guidance. - Workspace consumers must handle the new `workspace_seed` operation type. Shared types and UI state handling are updated in this pull request. > 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, high-reasoning mode, with repository, shell, and GitHub tool use. The runtime does not expose a more specific deployment suffix or 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 - [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 --- cli/src/__tests__/worktree.test.ts | 53 ++++++- cli/src/commands/worktree.ts | 37 ++++- doc/DEVELOPING.md | 3 + .../src/embedded-postgres-lifecycle.test.ts | 77 +++++++++++ .../db/src/embedded-postgres-lifecycle.ts | 54 ++++++++ packages/db/src/index.ts | 1 + .../shared/src/types/workspace-operation.ts | 1 + .../src/__tests__/workspace-runtime.test.ts | 101 ++++++++++++++ server/src/services/workspace-runtime.ts | 130 ++++++++++++++---- ui/src/lib/workspace-access-state.test.ts | 2 +- ui/src/lib/workspace-access-state.ts | 3 +- ...onWorkspaceDetail.provision-status.test.ts | 11 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 8 +- 13 files changed, 443 insertions(+), 38 deletions(-) create mode 100644 packages/db/src/embedded-postgres-lifecycle.test.ts create mode 100644 packages/db/src/embedded-postgres-lifecycle.ts diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index b4834dd5121..8c2f5e3fd1e 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -27,7 +27,9 @@ import { import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, + ensureEmbeddedPostgres, ensureWorktreeSeeded, + formatWorktreeSeedFailureDiagnostic, markWorktreeSeedPending, pauseSeededScheduledRoutines, quarantineSeededWorktreeExecutionState, @@ -488,6 +490,34 @@ describe("worktree helpers", () => { expect(full.nullifyColumns).toEqual({}); }); + it("requires the seed process to own the target embedded Postgres lifecycle", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-live-target-")); + try { + fs.writeFileSync( + path.join(tempRoot, "postmaster.pid"), + `${process.pid}\n${tempRoot}\n0\n55432\n`, + ); + + await expect(ensureEmbeddedPostgres(tempRoot, 55432, { allowExisting: false })) + .rejects.toThrow("while it is already running"); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("surfaces a credential-safe diagnostic when the target shuts down during restore", () => { + expect(formatWorktreeSeedFailureDiagnostic( + "restore", + new Error( + "Failed to restore seed.sql.gz: FATAL: the database system is shutting down; psql error: write EPIPE", + ), + )).toBe( + "Target embedded PostgreSQL shut down during restore. Stop any competing worktree service and retry the seed.", + ); + expect(formatWorktreeSeedFailureDiagnostic("migrations", new Error("secret connection failure"))) + .toBe("Seed failed during migrations."); + }); + it("rejects a source migration journal that diverges from the code journal", () => { expect(() => resolveWorktreeSeedMigrationRevision({ status: "upToDate", @@ -714,7 +744,7 @@ describe("worktree helpers", () => { }, ); - it("ensure-seeded keeps the pending marker when seeding fails", async () => { + it("ensure-seeded records a target shutdown diagnostic when restore fails", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-failure-")); try { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); @@ -746,13 +776,28 @@ describe("worktree helpers", () => { await expect( ensureWorktreeSeeded( { config: targetConfigPath, fromConfig: sourceConfigPath }, - { seedDatabase: vi.fn().mockRejectedValue(new Error("seed failed")) }, + { + seedDatabase: vi.fn(async (input) => { + input.onPhase?.("restore", "started"); + throw new Error( + "Failed to restore seed.sql.gz: FATAL: the database system is shutting down; psql error: write EPIPE", + ); + }), + }, ), - ).rejects.toThrow("seed failed"); + ).rejects.toThrow("database system is shutting down"); expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({ state: "failed", - phase: "pending", + phase: "restore", + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + phase: "restore", + status: "failed", + message: + "Target embedded PostgreSQL shut down during restore. Stop any competing worktree service and retry the seed.", + }), + ]), }); expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(false); expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(false); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index d31dabaefcb..e3dadf71e1f 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -55,6 +55,7 @@ import { workspaceRuntimeServices, createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError, + loadWithoutEmbeddedPostgresExitHooks, prepareEmbeddedPostgresNativeRuntime, } from "@paperclipai/db"; import type { Command } from "commander"; @@ -1105,11 +1106,15 @@ export function copySeededSecretsKey(input: { } } -async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { +export async function ensureEmbeddedPostgres( + dataDir: string, + preferredPort: number, + options: { allowExisting?: boolean } = {}, +): Promise { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; try { - const mod = await import(moduleName); + const mod = await loadWithoutEmbeddedPostgresExitHooks(() => import(moduleName)); EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; } catch { throw new Error( @@ -1121,6 +1126,12 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); const runningPid = readRunningPostmasterPid(postmasterPidFile); if (runningPid) { + if (options.allowExisting === false) { + throw new Error( + `Cannot seed target embedded PostgreSQL at ${dataDir} while it is already running (pid=${runningPid}). ` + + "Stop the worktree service that owns this database, then retry the seed.", + ); + } return { port: readPidFilePort(postmasterPidFile) ?? preferredPort, startedByThisProcess: false, @@ -1632,13 +1643,14 @@ async function seedWorktreeDatabase(input: { }); input.onPhase?.("snapshot", "succeeded", `Created ${path.basename(backup.backupFile)}.`); + input.onPhase?.("restore", "started"); targetHandle = await ensureEmbeddedPostgres( input.targetConfig.database.embeddedPostgresDataDir, input.targetConfig.database.embeddedPostgresPort, + { allowExisting: false }, ); const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`; - input.onPhase?.("restore", "started"); await resetPostgresDatabase(adminConnectionString, "paperclip"); const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`; await runDatabaseRestore({ @@ -1703,6 +1715,23 @@ const WORKTREE_SEED_DIAGNOSTIC_LIMIT = 32; const WORKTREE_SEED_DIAGNOSTIC_MESSAGE_LIMIT = 512; const activeSeedInterruptHandlers = new Map void>(); +export function formatWorktreeSeedFailureDiagnostic( + phase: WorktreeSeedPhase, + error: unknown, +): string { + const message = error instanceof Error ? error.message : String(error ?? ""); + if ( + phase === "restore" + && /database system is shutting down|terminating connection due to administrator command/i.test(message) + ) { + return "Target embedded PostgreSQL shut down during restore. Stop any competing worktree service and retry the seed."; + } + if (phase === "restore" && /Cannot seed target embedded PostgreSQL.+already running/i.test(message)) { + return "Target embedded PostgreSQL is owned by a running worktree service. Stop that service and retry the seed."; + } + return `Seed failed during ${phase}.`; +} + function dispatchSeedInterruption(signal: NodeJS.Signals): void { for (const handler of activeSeedInterruptHandlers.values()) { try { @@ -2096,7 +2125,7 @@ async function runVerifiedWorktreeSeed(input: { state: "failed", // Do not persist the underlying error: database/driver errors may contain // connection credentials. The CLI still returns the exact error to its caller. - message: `Seed failed during ${activePhase}.`, + message: formatWorktreeSeedFailureDiagnostic(activePhase, error), }); throw error; } finally { diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 82889da8e39..32791b0b216 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -486,10 +486,13 @@ The default `worktree init` still seeds eagerly. A lean worktree (created withou - `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source exclusively from the control-plane-provided base project workspace; manual worktrees must pass `--from-config`. - `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config `. - 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 manifest is not verified. An explicitly configured runtime provision command always takes precedence. +- The built-in deferred seed is recorded as its own terminal `workspace_seed` operation. A zero exit code is not enough for success: the operation succeeds only when `.paperclip/seed-manifest.json` contains complete verified evidence; failed, missing, or malformed manifests produce a failed operation with the seed phase in metadata. - Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned). Both `minimal` and `full` modes use the same terminal data-validation contract. Source validation accepts a migration journal that is a prefix of the checkout's journal and records the source revision in seed diagnostics; it rejects a source that is ahead of the checkout because that would require a downgrade. After restore, Paperclip applies pending migrations and requires the target journal to be current. Both validations also read a credential-backed auth user, instance administrator role, active company membership, and representative cloned company/issue pair. Restore, migrations, execution quarantine, routine pausing, workspace rebinding, and post-restore validation all run under the seed lock. An interruption leaves the exact active phase in terminal `failed` state; it cannot produce readiness evidence. +The seed process must own the target embedded PostgreSQL lifecycle for that entire sequence. It refuses to restore into a target postmaster that is already running, suppresses the embedded provider's process-global exit hooks, and stops its owned target only after validation or failure cleanup. A shutdown detected during restore is recorded as a target-database shutdown diagnostic rather than a generic restore failure. + The seed manifest never grants source-path authority. Its source path and instance are diagnostic assertions that must exactly match the realpath-canonical registered source before any lock, backup, service stop, spawn, or database mutation. Missing registration, sibling or foreign paths, symlink aliases, source/target identity collisions, and company mismatches fail closed. **Unverified-seed guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose manifest is pending, running, failed, malformed, or missing required verification evidence and points you at the fix: diff --git a/packages/db/src/embedded-postgres-lifecycle.test.ts b/packages/db/src/embedded-postgres-lifecycle.test.ts new file mode 100644 index 00000000000..7639fac645b --- /dev/null +++ b/packages/db/src/embedded-postgres-lifecycle.test.ts @@ -0,0 +1,77 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { loadWithoutEmbeddedPostgresExitHooks } from "./embedded-postgres-lifecycle.js"; + +describe("loadWithoutEmbeddedPostgresExitHooks", () => { + it("removes every eager exit hook from the real embedded-postgres import", async () => { + const eventNames = [ + "exit", + "beforeExit", + "SIGHUP", + "SIGINT", + "SIGTERM", + "SIGBREAK", + "message", + ]; + const before = new Map(eventNames.map((eventName) => [ + eventName, + process.rawListeners(eventName), + ])); + const moduleName = "embedded-postgres"; + + await loadWithoutEmbeddedPostgresExitHooks(() => import(moduleName)); + + for (const eventName of eventNames) { + expect(process.rawListeners(eventName)).toEqual(before.get(eventName)); + } + }); + + it("removes dependency exit hooks while preserving existing listeners", async () => { + const target = new EventEmitter(); + const existingSignalListener = vi.fn(); + const existingExitListener = vi.fn(); + target.on("SIGTERM", existingSignalListener); + target.on("exit", existingExitListener); + + const dependencyListener = vi.fn(); + const loaded = await loadWithoutEmbeddedPostgresExitHooks( + async () => { + for (const eventName of [ + "exit", + "beforeExit", + "SIGHUP", + "SIGINT", + "SIGTERM", + "SIGBREAK", + "message", + ]) { + target.on(eventName, dependencyListener); + } + return { default: class EmbeddedPostgres {} }; + }, + target, + ); + + expect(loaded.default.name).toBe("EmbeddedPostgres"); + expect(target.rawListeners("SIGTERM")).toEqual([existingSignalListener]); + expect(target.rawListeners("exit")).toEqual([existingExitListener]); + for (const eventName of ["beforeExit", "SIGHUP", "SIGINT", "SIGBREAK", "message"]) { + expect(target.rawListeners(eventName)).toEqual([]); + } + }); + + it("cleans up listeners even when the import fails", async () => { + const target = new EventEmitter(); + const dependencyListener = vi.fn(); + + await expect(loadWithoutEmbeddedPostgresExitHooks( + async () => { + target.on("SIGTERM", dependencyListener); + throw new Error("import failed"); + }, + target, + )).rejects.toThrow("import failed"); + + expect(target.rawListeners("SIGTERM")).toEqual([]); + }); +}); diff --git a/packages/db/src/embedded-postgres-lifecycle.ts b/packages/db/src/embedded-postgres-lifecycle.ts new file mode 100644 index 00000000000..5bd7e55faa8 --- /dev/null +++ b/packages/db/src/embedded-postgres-lifecycle.ts @@ -0,0 +1,54 @@ +const EMBEDDED_POSTGRES_EXIT_EVENTS = [ + "exit", + "beforeExit", + "SIGHUP", + "SIGINT", + "SIGTERM", + "SIGBREAK", + "message", +] as const; + +type EmbeddedPostgresExitTarget = { + rawListeners(eventName: string): Function[]; + removeListener(eventName: string, listener: (...args: any[]) => void): unknown; +}; + +/** + * embedded-postgres installs async-exit-hook listeners as an import side effect. + * Paperclip-managed clusters have an explicit owner and shutdown path, so those + * global listeners must not be allowed to stop a cluster independently of that + * owner (for example while a worktree seed restore is still streaming). + * + * Remove only listeners added by the supplied import and preserve every listener + * that was already registered by Paperclip or its host process. + */ +export async function loadWithoutEmbeddedPostgresExitHooks( + load: () => Promise, + target: EmbeddedPostgresExitTarget = process, +): Promise { + const listenersBeforeLoad = new Map( + EMBEDDED_POSTGRES_EXIT_EVENTS.map((eventName) => [ + eventName, + target.rawListeners(eventName), + ]), + ); + + let loaded: T; + try { + loaded = await load(); + } finally { + for (const eventName of EMBEDDED_POSTGRES_EXIT_EVENTS) { + const remainingBeforeLoad = [...(listenersBeforeLoad.get(eventName) ?? [])]; + for (const listener of target.rawListeners(eventName)) { + const existingIndex = remainingBeforeLoad.indexOf(listener); + if (existingIndex >= 0) { + remainingBeforeLoad.splice(existingIndex, 1); + continue; + } + target.removeListener(eventName, listener as (...args: any[]) => void); + } + } + } + + return loaded; +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index ff1a803dba6..0957f095de1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -35,6 +35,7 @@ export { ensureLinuxSharedLibraryAliases, prepareEmbeddedPostgresNativeRuntime, } from "./embedded-postgres-native.js"; +export { loadWithoutEmbeddedPostgresExitHooks } from "./embedded-postgres-lifecycle.js"; export { issueRelations } from "./schema/issue_relations.js"; export { issueReferenceMentions } from "./schema/issue_reference_mentions.js"; export * from "./schema/index.js"; diff --git a/packages/shared/src/types/workspace-operation.ts b/packages/shared/src/types/workspace-operation.ts index 345e306ca7a..8fcb52c7088 100644 --- a/packages/shared/src/types/workspace-operation.ts +++ b/packages/shared/src/types/workspace-operation.ts @@ -2,6 +2,7 @@ export type WorkspaceOperationPhase = | "worktree_prepare" | "workspace_config_freshness" | "workspace_provision" + | "workspace_seed" | "workspace_runtime_provision" | "workspace_repair" | "workspace_teardown" diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 8b122f78640..7f67755c2ae 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -3882,6 +3882,107 @@ describe("ensureRuntimeServicesForRun", () => { } }); + it("records the built-in deferred seed as failed when its manifest is not verified", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-seed-operation-")); + const restorePaperclipEnv = configureRuntimeProvisionTestHome(workspaceRoot, "workspace-seed-operation"); + const scriptsDir = path.join(workspaceRoot, "scripts"); + const markerDir = path.join(workspaceRoot, ".paperclip"); + await fs.mkdir(scriptsDir, { recursive: true }); + await fs.mkdir(markerDir, { recursive: true }); + await fs.writeFile(path.join(markerDir, "seed-pending"), "{}\n", "utf8"); + await fs.writeFile( + path.join(scriptsDir, "provision-worktree-runtime.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `printf '%s\\n' '${JSON.stringify({ version: 2, state: "failed", phase: "source_validation" })}' > .paperclip/seed-manifest.json`, + ].join("\n"), + "utf8", + ); + const config = runtimeProvisionTestConfig({}); + const workspace = { + ...buildWorkspace(workspaceRoot), + source: "task_session" as const, + strategy: "git_worktree" as const, + worktreePath: workspaceRoot, + }; + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + try { + await expect( + startRuntimeServicesForWorkspaceControl( + runtimeProvisionStartInput({ workspace, config, recorder }), + ), + ).rejects.toThrow(/without a verified manifest.*source_validation/); + + expect(operations).toEqual([ + expect.objectContaining({ + phase: "workspace_seed", + result: expect.objectContaining({ + status: "failed", + exitCode: 1, + metadata: expect.objectContaining({ + provisionKind: "workspace_seed", + seedState: "failed", + seedPhase: "source_validation", + seedFailurePhase: "source_validation", + }), + }), + }), + ]); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-1", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + restorePaperclipEnv(); + } + }); + + it("keeps an explicit command matching the built-in seed command as runtime provisioning", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-explicit-runtime-provision-")); + const restorePaperclipEnv = configureRuntimeProvisionTestHome(workspaceRoot, "explicit-runtime-provision"); + const scriptsDir = path.join(workspaceRoot, "scripts"); + await fs.mkdir(scriptsDir, { recursive: true }); + await fs.writeFile( + path.join(scriptsDir, "provision-worktree-runtime.sh"), + "#!/usr/bin/env bash\nset -euo pipefail\n", + "utf8", + ); + const config = runtimeProvisionTestConfig({ + provisionCommand: "bash ./scripts/provision-worktree-runtime.sh", + }); + const workspace = buildWorkspace(workspaceRoot); + const { recorder, operations } = createWorkspaceOperationRecorderDouble(); + + try { + const services = await startRuntimeServicesForWorkspaceControl( + runtimeProvisionStartInput({ workspace, config, recorder }), + ); + + expect(services).toHaveLength(1); + expect(operations).toEqual([ + expect.objectContaining({ + phase: "workspace_runtime_provision", + result: expect.objectContaining({ + status: "succeeded", + metadata: expect.objectContaining({ + provisionKind: "runtime_dependencies", + }), + }), + }), + ]); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-1", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + restorePaperclipEnv(); + } + }); + it("does not create a runtime provision operation when the command is absent", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-provision-noop-")); const restorePaperclipEnv = configureRuntimeProvisionTestHome(workspaceRoot, "runtime-provision-noop"); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index a9cff2ecbe1..0e4656c13fc 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -36,7 +36,7 @@ import { and, desc, eq, gte, inArray, isNull, lte, ne, or } from "drizzle-orm"; import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js"; import { conflict } from "../errors.js"; import { resolveHomeAwarePath } from "../home-paths.js"; -import { hasVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js"; +import { hasVerifiedWorktreeSeedManifest, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js"; import { buildManagedWorkspaceGuestEnv, logManagedWorkspaceReadinessRejection, @@ -2871,7 +2871,7 @@ async function recordGitOperation( async function recordWorkspaceCommandOperation( recorder: WorkspaceOperationRecorder | null | undefined, input: { - phase: "workspace_provision" | "workspace_runtime_provision" | "workspace_teardown"; + phase: "workspace_provision" | "workspace_seed" | "workspace_runtime_provision" | "workspace_teardown"; command: string; resolvedCommand?: string; cwd: string; @@ -2903,26 +2903,31 @@ async function recordWorkspaceCommandOperation( cwd: input.cwd, env: input.env, }); + const seedEvidence = input.phase === "workspace_seed" + ? readWorkspaceSeedOperationEvidence(input.cwd) + : null; stdout = result.stdout; - stderr = result.stderr; - code = result.code; + stderr = [result.stderr, seedEvidence?.error].filter(Boolean).join("\n"); + code = result.code === 0 && seedEvidence && !seedEvidence.verified ? 1 : result.code; if (result.stdout && input.onLog) await input.onLog("stdout", `[runtime-provision] ${result.stdout}`); - if (result.stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${result.stderr}`); + if (stderr && input.onLog) await input.onLog("stderr", `[runtime-provision] ${stderr}`); + const truncationMetadata = result.stdoutTruncated || result.stderrTruncated + ? { + stdoutTruncated: result.stdoutTruncated, + stderrTruncated: result.stderrTruncated, + stdoutBytes: result.stdoutBytes, + stderrBytes: result.stderrBytes, + } + : null; return { - status: result.code === 0 ? "succeeded" : "failed", - exitCode: result.code, + status: code === 0 ? "succeeded" : "failed", + exitCode: code, stdout: result.stdout, - stderr: result.stderr, - system: result.code === 0 ? input.successMessage ?? null : null, - metadata: - result.stdoutTruncated || result.stderrTruncated - ? { - stdoutTruncated: result.stdoutTruncated, - stderrTruncated: result.stderrTruncated, - stdoutBytes: result.stdoutBytes, - stderrBytes: result.stderrBytes, - } - : null, + stderr, + system: code === 0 ? input.successMessage ?? null : null, + metadata: seedEvidence + ? { ...seedEvidence.metadata, ...(truncationMetadata ?? {}) } + : truncationMetadata, }; }, }); @@ -5052,6 +5057,7 @@ type StartLocalRuntimeServiceInput = { service: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; runtimeProvisionCommand?: string | null; + runtimeProvisionKind?: RuntimeProvisionKind | null; recorder?: WorkspaceOperationRecorder | null; provisionCoordinator?: RuntimeProvisionCoordinator; preparedProvisioningRecord?: RuntimeServiceRecord | null; @@ -5079,6 +5085,49 @@ function readRuntimeProvisionCommand(config: Record) { ).trim(); } +const BUILTIN_WORKSPACE_SEED_COMMAND = "bash ./scripts/provision-worktree-runtime.sh"; + +type RuntimeProvisionKind = "workspace_seed" | "runtime_dependencies"; + +function readWorkspaceSeedOperationEvidence(worktreePath: string): { + verified: boolean; + error: string | null; + metadata: Record; +} { + const manifestPath = path.join(worktreePath, ".paperclip", "seed-manifest.json"); + try { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + const state = typeof manifest.state === "string" ? manifest.state : "unknown"; + const phase = typeof manifest.phase === "string" ? manifest.phase : null; + const verified = isVerifiedWorktreeSeedManifest(manifest); + return { + verified, + error: verified + ? null + : phase + ? `Workspace seed command returned without a verified manifest (state: ${state}, phase: ${phase}).` + : `Workspace seed command returned without a verified manifest (state: ${state}).`, + metadata: { + provisionKind: "workspace_seed", + seedState: state, + seedPhase: phase, + seedFailurePhase: state === "failed" ? phase : null, + }, + }; + } catch { + return { + verified: false, + error: "Workspace seed command returned without a readable seed manifest.", + metadata: { + provisionKind: "workspace_seed", + seedState: existsSync(manifestPath) ? "unreadable" : "absent", + seedPhase: null, + seedFailurePhase: "seed_manifest_unreadable", + }, + }; + } +} + export function resolveRuntimeProvisionCommand(input: { config: Record; workspace: RealizedExecutionWorkspace; @@ -5105,7 +5154,21 @@ export function resolveRuntimeProvisionCommand(input: { return ""; } - return "bash ./scripts/provision-worktree-runtime.sh"; + return BUILTIN_WORKSPACE_SEED_COMMAND; +} + +function resolveRuntimeProvision(input: { + config: Record; + workspace: RealizedExecutionWorkspace; +}): { command: string; kind: RuntimeProvisionKind | null } { + const command = resolveRuntimeProvisionCommand(input); + if (!command) return { command, kind: null }; + return { + command, + kind: readRuntimeProvisionCommand(input.config) + ? "runtime_dependencies" + : "workspace_seed", + }; } function runtimeProvisionWorkspaceKey(input: StartLocalRuntimeServiceInput) { @@ -5136,8 +5199,9 @@ async function runRuntimeProvisionWithWorkspaceMutex(input: StartLocalRuntimeSer }) : null); const resolvedCommand = resolveRepoManagedWorkspaceCommand(command, input.workspace.baseCwd); + const workspaceSeed = input.runtimeProvisionKind === "workspace_seed"; const promise = recordWorkspaceCommandOperation(recorder, { - phase: "workspace_runtime_provision", + phase: workspaceSeed ? "workspace_seed" : "workspace_runtime_provision", command, resolvedCommand, cwd: input.workspace.cwd, @@ -5150,14 +5214,19 @@ async function runRuntimeProvisionWithWorkspaceMutex(input: StartLocalRuntimeSer agent: input.agent, created: input.workspace.created, }), - label: `Runtime provision command "${command}"`, + label: workspaceSeed + ? `Workspace seed command "${command}"` + : `Runtime provision command "${command}"`, metadata: { executionWorkspaceId: input.executionWorkspaceId ?? null, projectWorkspaceId: input.workspace.workspaceId, serviceName: asString(input.service.name, "service"), + provisionKind: workspaceSeed ? "workspace_seed" : "runtime_dependencies", resolvedCommand: resolvedCommand === command ? null : resolvedCommand, }, - successMessage: `Provisioned runtime dependencies for ${input.workspace.cwd}\n`, + successMessage: workspaceSeed + ? `Verified the workspace database seed for ${input.workspace.cwd}\n` + : `Provisioned runtime dependencies for ${input.workspace.cwd}\n`, onLog: input.onLog, }).then(() => undefined); @@ -6465,7 +6534,8 @@ async function ensureRuntimeServicesForRunInvocation( }); const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; - const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input); + const runtimeProvision = resolveRuntimeProvision(input); + const runtimeProvisionCommand = runtimeProvision.command; const provisionCoordinator = createRuntimeProvisionCoordinator(); const allowFixedPortFallback = await isPersistedIsolatedExecutionWorkspace({ db: input.db, @@ -6523,6 +6593,7 @@ async function ensureRuntimeServicesForRunInvocation( service, onLog: input.onLog, runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, recorder: input.recorder, provisionCoordinator, allowFixedPortFallback, @@ -6693,6 +6764,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( deferReadiness?: boolean; allowFixedPortFallback?: boolean; runtimeProvisionCommand?: string; + runtimeProvisionKind?: RuntimeProvisionKind | null; provisionCoordinator?: RuntimeProvisionCoordinator; preparedProvisioning?: { service: Record; @@ -6759,6 +6831,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( service, onLog: input.onLog, runtimeProvisionCommand: options?.runtimeProvisionCommand, + runtimeProvisionKind: options?.runtimeProvisionKind, recorder: input.recorder, provisionCoordinator: options?.provisionCoordinator, preparedProvisioningRecord: @@ -6861,7 +6934,8 @@ async function startRuntimeServicesForWorkspaceControlInvocation( serviceStates: readConfiguredServiceStates(input.config), }); const invocationId = input.invocationId ?? randomUUID(); - const runtimeProvisionCommand = resolveRuntimeProvisionCommand(input); + const runtimeProvision = resolveRuntimeProvision(input); + const runtimeProvisionCommand = runtimeProvision.command; const provisionCoordinator = createRuntimeProvisionCoordinator(); const hasHttpsExposure = await anyRuntimeServiceUsesHttpsExposure(rawServices); @@ -6880,7 +6954,11 @@ async function startRuntimeServicesForWorkspaceControlInvocation( invocationId, input.db, input.db, - { runtimeProvisionCommand, provisionCoordinator }, + { + runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, + provisionCoordinator, + }, ); return batch.refs; } @@ -6931,6 +7009,7 @@ async function startRuntimeServicesForWorkspaceControlInvocation( service, onLog: input.onLog, runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, recorder: input.recorder, provisionCoordinator, reuseKey, @@ -6959,6 +7038,7 @@ async function startRuntimeServicesForWorkspaceControlInvocation( deferReadiness: true, allowFixedPortFallback, runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, provisionCoordinator, preparedProvisioning, }, diff --git a/ui/src/lib/workspace-access-state.test.ts b/ui/src/lib/workspace-access-state.test.ts index 1ef00cb8933..ffd60795bff 100644 --- a/ui/src/lib/workspace-access-state.test.ts +++ b/ui/src/lib/workspace-access-state.test.ts @@ -45,7 +45,7 @@ function operation(overrides: Partial = {}): WorkspaceOperat executionWorkspaceId: "ews-1", heartbeatRunId: null, issueId: null, - phase: "workspace_runtime_provision", + phase: "workspace_seed", command: null, cwd: null, status: "succeeded", diff --git a/ui/src/lib/workspace-access-state.ts b/ui/src/lib/workspace-access-state.ts index 0747d239d4d..7925008669a 100644 --- a/ui/src/lib/workspace-access-state.ts +++ b/ui/src/lib/workspace-access-state.ts @@ -125,7 +125,8 @@ export function resolveWorkspaceAccessState(input: { const runtimeServices = input.runtimeServices ?? []; const repair = latestOperation(operations, "workspace_repair"); const provision = - latestOperation(operations, "workspace_runtime_provision") + latestOperation(operations, "workspace_seed") + ?? latestOperation(operations, "workspace_runtime_provision") ?? latestOperation(operations, "workspace_provision"); const failure = input.handoffFailure ?? null; const cause = describeWorkspaceReadinessCause(failure); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.provision-status.test.ts b/ui/src/pages/ExecutionWorkspaceDetail.provision-status.test.ts index 337c7f283ca..d532410b5d3 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.provision-status.test.ts +++ b/ui/src/pages/ExecutionWorkspaceDetail.provision-status.test.ts @@ -9,7 +9,7 @@ function operation(overrides: Partial = {}): WorkspaceOperat executionWorkspaceId: "ews-1", heartbeatRunId: null, issueId: null, - phase: overrides.phase ?? "workspace_runtime_provision", + phase: overrides.phase ?? "workspace_seed", command: overrides.command ?? "bash ./scripts/provision-worktree-runtime.sh", cwd: null, status: overrides.status ?? "succeeded", @@ -58,6 +58,15 @@ describe("resolveRuntimeProvisionStatus", () => { ).toEqual({ kind: "provisioned", at: finishedAt }); }); + it("continues to recognize custom runtime dependency provisioning", () => { + expect( + resolveRuntimeProvisionStatus({ + runtimeProvisionCommand: "pnpm install", + operations: [operation({ phase: "workspace_runtime_provision" })], + }), + ).toMatchObject({ kind: "provisioned" }); + }); + it("reports provisioning while the op is running", () => { const startedAt = new Date("2026-08-01T12:00:00.000Z"); expect( diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index 6fea2668d74..6d4be124fed 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -377,6 +377,8 @@ function workspaceOperationPhaseLabel(phase: string) { return "Config freshness"; case "workspace_provision": return "Provision"; + case "workspace_seed": + return "Database seed"; case "workspace_runtime_provision": return "Runtime provision"; case "workspace_teardown": @@ -399,14 +401,16 @@ export type RuntimeProvisionStatus = /** * Derives the lazy runtime-provisioning state from the configured command and the - * `workspace_runtime_provision` operation-log entries (most-recent first). Returns + * database-seed or runtime-provision operation-log entries (most-recent first). Returns * "eager" when no runtime provision command is configured (the legacy path). */ export function resolveRuntimeProvisionStatus(input: { runtimeProvisionCommand: string | null | undefined; operations: WorkspaceOperation[] | undefined; }): RuntimeProvisionStatus { - const latest = (input.operations ?? []).find((operation) => operation.phase === "workspace_runtime_provision") ?? null; + const latest = (input.operations ?? []).find((operation) => ( + operation.phase === "workspace_seed" || operation.phase === "workspace_runtime_provision" + )) ?? null; if (latest) { const at = latest.finishedAt ?? latest.startedAt ?? null; if (latest.status === "running") return { kind: "provisioning", at }; From e0d46d13750baaa7b6ff98b6d380bdb21e594cc3 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:01:24 -0500 Subject: [PATCH 24/39] test(workspaces): fix runtime provision metadata assertion (#11707) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip manages agent work in isolated execution workspaces. > - Workspace operations record operation metadata and command-result metadata separately. > - Runtime provisioning records its provision kind in the operation metadata. > - One merged regression test checked that value in the command-result metadata. > - The production behavior was correct, but the test failed. > - This pull request checks the provision kind in the operation metadata. > - The benefit is that the regression test now matches the recorder contract. ## Linked Issues or Issue Description Related pull request: #11706 **What happened?** The runtime provisioning regression test expected `provisionKind` in `result.metadata`. The recorder stores this value in the operation's top-level `metadata`. The command-result metadata is `null` for this case. **Expected behavior** The test must check `metadata.provisionKind`. It must continue to check `result.status`. **Steps to reproduce** 1. Check out commit `e1df4c6068fea684a1e9714ebd64bce95f3db19a`. 2. Run the focused runtime provisioning test. 3. Observe that the assertion checks the wrong metadata object. **Paperclip version or commit** `e1df4c6068fea684a1e9714ebd64bce95f3db19a` **Deployment mode** Local development. ## What Changed - Move the `provisionKind` assertion from `result.metadata` to the operation's top-level `metadata`. - Keep the `result.status` assertion unchanged. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/workspace-runtime.test.ts -t "keeps an explicit command matching the built-in seed command as runtime provisioning"` - Result: 1 test passed and 125 tests skipped. ## Risks - Low risk. This pull request changes one test assertion and does not change production code. > 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, high-reasoning mode, with repository, shell, Git, GitHub, and code execution tools. The runtime does not expose 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 - [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__/workspace-runtime.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 7f67755c2ae..b469d1cc775 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -3965,11 +3965,11 @@ describe("ensureRuntimeServicesForRun", () => { expect(operations).toEqual([ expect.objectContaining({ phase: "workspace_runtime_provision", + metadata: expect.objectContaining({ + provisionKind: "runtime_dependencies", + }), result: expect.objectContaining({ status: "succeeded", - metadata: expect.objectContaining({ - provisionKind: "runtime_dependencies", - }), }), }), ]); From 8b2dbd9d8716dab37325ad41618a32b12053c324 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 19 Aug 2026 09:13:27 -0700 Subject: [PATCH 25/39] chore(deps): open one dependabot PR per major update (#11709) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Dependabot keeps the project dependencies current. > - The npm update rule groups all major updates into one pull request. > - One blocked major update can stop that grouped pull request. > - This pull request gives each major update its own pull request. > - The higher limit keeps the initial major updates and normal updates visible. ## Linked Issues or Issue Description **What happened?** Dependabot grouped all npm major updates into one weekly pull request. A blocked upgrade stopped the grouped pull request and hid other ready updates. **Expected behavior** Dependabot should open one pull request for each npm major update. **Steps to reproduce** 1. Read the npm entry in `.github/dependabot.yml`. 2. Run the weekly Dependabot update. 3. Inspect the pull requests for major npm updates. **Paperclip version or commit** `master` at the base commit for this pull request. **Deployment mode** Not applicable. This change affects repository configuration. **Installation method** Not applicable. This change affects repository configuration. **Agent adapter(s) involved** Not adapter-specific (repository configuration). **Database mode** Not database-related. **Additional context** The grouped major update included a blocked `js-yaml` upgrade. Separate pull requests let other major updates proceed independently. ## What Changed - Remove the `major-updates` group from the npm Dependabot entry. - Raise the npm `open-pull-requests-limit` from 10 to 20. - Keep the GitHub Actions Dependabot entry unchanged. ## Verification - Run `python3 -c "import yaml; yaml.safe_load(open('.github/dependabot.yml'))"`. - Confirm the diff changes only `.github/dependabot.yml`. - Confirm CI passes on this pull request. ## Risks This change can open more Dependabot pull requests. The limit of 20 bounds the number of open npm update pull requests. No application code changes. ## Model Used OpenAI GPT-5. Tool use and code repository inspection assisted this change. ## 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 --- .github/dependabot.yml | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 33caa46d21c..3ad56855a24 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,19 +6,14 @@ updates: interval: weekly day: monday time: "06:00" - open-pull-requests-limit: 10 + # Each dependency update gets its own pull request, including major + # version bumps. A grouped major PR cannot merge when one member has + # a blocked upgrade, so it hides the other ready majors. Separate PRs + # let each major land on its own. The limit holds the initial burst + # of pending majors plus the regular minor and patch updates. + open-pull-requests-limit: 20 labels: - "dependencies" - groups: - # Bundle all major version bumps into a single weekly PR so they - # stay visible without flooding the PR list. Minor/patch updates - # still get individual PRs. - major-updates: - applies-to: version-updates - update-types: - - "major" - patterns: - - "*" - package-ecosystem: github-actions directory: "/" From ec8d7dd21f684b4dd1637f9ac2ed44ef9318d669 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:13 -0700 Subject: [PATCH 26/39] build(deps): bump ws from 8.21.1 to 8.21.3 (#11729) Bumps [ws](https://github.com/websockets/ws) from 8.21.1 to 8.21.3.
Release notes

Sourced from ws's releases.

8.21.3

Bug fixes

  • The server now correctly rejects permessage-deflate offers if the incoming client_max_window_bits parameter value is smaller than its configured clientMaxWindowBits (e97a20ea).

8.21.2

Bug fixes

  • Fixed a test for CITGM (2eb3be0b).
Commits
  • c791e70 [dist] 8.21.3
  • e97a20e [fix] Reject offers with client_max_window_bits below config
  • 787ebf2 [dist] 8.21.2
  • b4d62eb Revert "[ci] Trust Coveralls Homebrew tap"
  • e4bb883 [security] Use GitHub PVR as main reporting channel
  • 2eb3be0 [test] Skip test on Node.js versions where it does not apply
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=ws&package-manager=npm_and_yarn&previous-version=8.21.1&new-version=8.21.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- packages/adapters/openclaw-gateway/package.json | 2 +- pnpm-lock.yaml | 16 ++++++++-------- server/package.json | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json index 5fdb8ea9724..9a34601ef41 100644 --- a/packages/adapters/openclaw-gateway/package.json +++ b/packages/adapters/openclaw-gateway/package.json @@ -52,7 +52,7 @@ "dependencies": { "@paperclipai/adapter-utils": "workspace:*", "picocolors": "^1.1.1", - "ws": "^8.21.1" + "ws": "^8.21.3" }, "devDependencies": { "@types/node": "^22.20.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d891504e47..9454d200425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,8 +285,8 @@ importers: specifier: ^1.1.1 version: 1.1.1 ws: - specifier: ^8.21.1 - version: 8.21.1 + specifier: ^8.21.3 + version: 8.21.3 devDependencies: '@types/node': specifier: ^22.20.1 @@ -840,8 +840,8 @@ importers: specifier: ^1.17.0 version: 1.17.0 ws: - specifier: ^8.21.1 - version: 8.21.1 + specifier: ^8.21.3 + version: 8.21.3 zod: specifier: ^3.24.2 version: 3.25.76 @@ -8711,8 +8711,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.21.1: - resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -16606,7 +16606,7 @@ snapshots: recast: 0.23.12 semver: 7.8.5 use-sync-external-store: 1.6.0(react@19.2.7) - ws: 8.21.1 + ws: 8.21.3 optionalDependencies: '@types/react': 19.2.17 transitivePeerDependencies: @@ -17222,7 +17222,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.1: {} + ws@8.21.3: {} wsl-utils@0.1.0: dependencies: diff --git a/server/package.json b/server/package.json index 8410e8e088e..344e9fea609 100644 --- a/server/package.json +++ b/server/package.json @@ -78,7 +78,7 @@ "pino-pretty": "^13.1.3", "sharp": "^0.35.3", "ssh2": "^1.17.0", - "ws": "^8.21.1", + "ws": "^8.21.3", "zod": "^3.24.2" }, "devDependencies": { From 98298cb7ff816369efbba976e6cbf64e7c5c3a4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:32:04 -0700 Subject: [PATCH 27/39] build(deps-dev): bump tsx from 4.23.1 to 4.23.12 (#11722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.1 to 4.23.12.
Release notes

Sourced from tsx's releases.

v4.23.12

4.23.12 (2026-08-10)

Bug Fixes

  • shim import.meta when tokens are split by comments or newlines (#829) (ed9d330), closes #828

This release is also available on:

v4.23.11

4.23.11 (2026-08-07)

Bug Fixes

  • preserve async ESM require fallback (55cbece)

This release is also available on:

v4.23.10

4.23.10 (2026-08-07)

Bug Fixes


This release is also available on:

v4.23.9

4.23.9 (2026-08-06)

Bug Fixes

  • map Node test locations (2f55884)
  • support data URLs in tsImport (b94f46f)

This release is also available on:

v4.23.8

... (truncated)

Commits
  • ed9d330 fix: shim import.meta when tokens are split by comments or newlines (#829)
  • 651f5be test: cover CommonJS TypeScript import.meta paths
  • bd3bc64 test: cover CommonJS loader source fallback
  • 55cbece fix: preserve async ESM require fallback
  • 6c5ba85 docs: document CommonJS default interop
  • ec1bcd5 fix: support nyc coverage discovery (#710)
  • b6e5b48 docs: clarify CommonJS default imports
  • 2f55884 fix: map Node test locations
  • de935d5 docs: document Node source-map stack formatting
  • b94f46f fix: support data URLs in tsImport
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tsx&package-manager=npm_and_yarn&previous-version=4.23.1&new-version=4.23.12)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- cli/package.json | 2 +- packages/db/package.json | 2 +- pnpm-lock.yaml | 426 +++++++++++++++++++++++++++++++-------- server/package.json | 2 +- 4 files changed, 350 insertions(+), 82 deletions(-) diff --git a/cli/package.json b/cli/package.json index 95cb206c4fe..6d5cc10a218 100644 --- a/cli/package.json +++ b/cli/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", - "tsx": "^4.23.1", + "tsx": "^4.23.12", "typescript": "^5.7.3" } } diff --git a/packages/db/package.json b/packages/db/package.json index 8ed466d9d73..1b2bbdb2002 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -55,7 +55,7 @@ "devDependencies": { "@types/node": "^22.20.1", "drizzle-kit": "^0.31.10", - "tsx": "^4.23.1", + "tsx": "^4.23.12", "typescript": "^5.7.3", "vitest": "^4.1.10" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9454d200425..b2442a88d49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,7 +45,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) cli: dependencies: @@ -114,8 +114,8 @@ importers: specifier: ^22.20.1 version: 22.20.1 tsx: - specifier: ^4.23.1 - version: 4.23.1 + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -258,7 +258,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/adapters/hermes-gateway: dependencies: @@ -274,7 +274,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/adapters/openclaw-gateway: dependencies: @@ -352,14 +352,14 @@ importers: specifier: ^0.31.10 version: 0.31.10 tsx: - specifier: ^4.23.1 - version: 4.23.1 + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^5.7.3 version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/google-sheets-mcp-server: dependencies: @@ -381,7 +381,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/kv-demo-mcp-server: dependencies: @@ -400,7 +400,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/mcp-server: dependencies: @@ -422,7 +422,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/plugins/create-paperclip-plugin: dependencies: @@ -472,7 +472,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/plugins/examples/plugin-file-browser-example: dependencies: @@ -590,7 +590,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/plugins/plugin-llm-wiki: dependencies: @@ -633,7 +633,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/plugins/plugin-workspace-diff: dependencies: @@ -667,7 +667,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/plugins/sdk: dependencies: @@ -724,7 +724,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.10 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages/teams-catalog: devDependencies: @@ -793,7 +793,7 @@ importers: version: 3.0.1(ajv@8.20.0) better-auth: specifier: 1.6.28 - version: 1.6.28(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))) + version: 1.6.28(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))) chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -877,17 +877,17 @@ importers: specifier: ^7.0.0 version: 7.2.2 tsx: - specifier: ^4.23.1 - version: 4.23.1 + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^5.7.3 version: 5.9.3 vite: specifier: ^6.4.3 - version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) ui: dependencies: @@ -1017,13 +1017,13 @@ importers: version: 10.5.4(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7)) '@storybook/addon-docs': specifier: 10.5.8 - version: 10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@storybook/react-vite': specifier: 10.5.8 - version: 10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@tailwindcss/vite': specifier: ^4.3.3 - version: 4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@types/node': specifier: ^22.20.1 version: 22.20.1 @@ -1035,7 +1035,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) storybook: specifier: 10.5.5 version: 10.5.5(@types/react@19.2.17)(react@19.2.7) @@ -1047,10 +1047,10 @@ importers: version: 5.9.3 vite: specifier: ^6.4.3 - version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + version: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) vitest: specifier: ^4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) packages: @@ -1889,6 +1889,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.18.20': resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} engines: {node: '>=12'} @@ -1913,6 +1919,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.18.20': resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} engines: {node: '>=12'} @@ -1937,6 +1949,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.18.20': resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} engines: {node: '>=12'} @@ -1961,6 +1979,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.18.20': resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} engines: {node: '>=12'} @@ -1985,6 +2009,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.18.20': resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} engines: {node: '>=12'} @@ -2009,6 +2039,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} engines: {node: '>=12'} @@ -2033,6 +2069,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} engines: {node: '>=12'} @@ -2057,6 +2099,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.18.20': resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} engines: {node: '>=12'} @@ -2081,6 +2129,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.18.20': resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} engines: {node: '>=12'} @@ -2105,6 +2159,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.18.20': resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} engines: {node: '>=12'} @@ -2129,6 +2189,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.18.20': resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} engines: {node: '>=12'} @@ -2153,6 +2219,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.18.20': resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} engines: {node: '>=12'} @@ -2177,6 +2249,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.18.20': resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} engines: {node: '>=12'} @@ -2201,6 +2279,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.18.20': resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} engines: {node: '>=12'} @@ -2225,6 +2309,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.18.20': resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} engines: {node: '>=12'} @@ -2249,6 +2339,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.18.20': resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} engines: {node: '>=12'} @@ -2273,6 +2369,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -2291,6 +2393,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} engines: {node: '>=12'} @@ -2315,6 +2423,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -2333,6 +2447,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} engines: {node: '>=12'} @@ -2357,6 +2477,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -2375,6 +2501,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.18.20': resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} engines: {node: '>=12'} @@ -2399,6 +2531,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.18.20': resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} engines: {node: '>=12'} @@ -2423,6 +2561,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.18.20': resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} engines: {node: '>=12'} @@ -2447,6 +2591,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.18.20': resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} engines: {node: '>=12'} @@ -2471,6 +2621,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@exodus/bytes@1.15.0': resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -6344,6 +6500,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -8312,8 +8473,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.23.1: - resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} engines: {node: '>=18.0.0'} hasBin: true @@ -9933,6 +10094,9 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + '@esbuild/android-arm64@0.18.20': optional: true @@ -9945,6 +10109,9 @@ snapshots: '@esbuild/android-arm64@0.28.1': optional: true + '@esbuild/android-arm64@0.28.2': + optional: true + '@esbuild/android-arm@0.18.20': optional: true @@ -9957,6 +10124,9 @@ snapshots: '@esbuild/android-arm@0.28.1': optional: true + '@esbuild/android-arm@0.28.2': + optional: true + '@esbuild/android-x64@0.18.20': optional: true @@ -9969,6 +10139,9 @@ snapshots: '@esbuild/android-x64@0.28.1': optional: true + '@esbuild/android-x64@0.28.2': + optional: true + '@esbuild/darwin-arm64@0.18.20': optional: true @@ -9981,6 +10154,9 @@ snapshots: '@esbuild/darwin-arm64@0.28.1': optional: true + '@esbuild/darwin-arm64@0.28.2': + optional: true + '@esbuild/darwin-x64@0.18.20': optional: true @@ -9993,6 +10169,9 @@ snapshots: '@esbuild/darwin-x64@0.28.1': optional: true + '@esbuild/darwin-x64@0.28.2': + optional: true + '@esbuild/freebsd-arm64@0.18.20': optional: true @@ -10005,6 +10184,9 @@ snapshots: '@esbuild/freebsd-arm64@0.28.1': optional: true + '@esbuild/freebsd-arm64@0.28.2': + optional: true + '@esbuild/freebsd-x64@0.18.20': optional: true @@ -10017,6 +10199,9 @@ snapshots: '@esbuild/freebsd-x64@0.28.1': optional: true + '@esbuild/freebsd-x64@0.28.2': + optional: true + '@esbuild/linux-arm64@0.18.20': optional: true @@ -10029,6 +10214,9 @@ snapshots: '@esbuild/linux-arm64@0.28.1': optional: true + '@esbuild/linux-arm64@0.28.2': + optional: true + '@esbuild/linux-arm@0.18.20': optional: true @@ -10041,6 +10229,9 @@ snapshots: '@esbuild/linux-arm@0.28.1': optional: true + '@esbuild/linux-arm@0.28.2': + optional: true + '@esbuild/linux-ia32@0.18.20': optional: true @@ -10053,6 +10244,9 @@ snapshots: '@esbuild/linux-ia32@0.28.1': optional: true + '@esbuild/linux-ia32@0.28.2': + optional: true + '@esbuild/linux-loong64@0.18.20': optional: true @@ -10065,6 +10259,9 @@ snapshots: '@esbuild/linux-loong64@0.28.1': optional: true + '@esbuild/linux-loong64@0.28.2': + optional: true + '@esbuild/linux-mips64el@0.18.20': optional: true @@ -10077,6 +10274,9 @@ snapshots: '@esbuild/linux-mips64el@0.28.1': optional: true + '@esbuild/linux-mips64el@0.28.2': + optional: true + '@esbuild/linux-ppc64@0.18.20': optional: true @@ -10089,6 +10289,9 @@ snapshots: '@esbuild/linux-ppc64@0.28.1': optional: true + '@esbuild/linux-ppc64@0.28.2': + optional: true + '@esbuild/linux-riscv64@0.18.20': optional: true @@ -10101,6 +10304,9 @@ snapshots: '@esbuild/linux-riscv64@0.28.1': optional: true + '@esbuild/linux-riscv64@0.28.2': + optional: true + '@esbuild/linux-s390x@0.18.20': optional: true @@ -10113,6 +10319,9 @@ snapshots: '@esbuild/linux-s390x@0.28.1': optional: true + '@esbuild/linux-s390x@0.28.2': + optional: true + '@esbuild/linux-x64@0.18.20': optional: true @@ -10125,6 +10334,9 @@ snapshots: '@esbuild/linux-x64@0.28.1': optional: true + '@esbuild/linux-x64@0.28.2': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -10134,6 +10346,9 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true + '@esbuild/netbsd-arm64@0.28.2': + optional: true + '@esbuild/netbsd-x64@0.18.20': optional: true @@ -10146,6 +10361,9 @@ snapshots: '@esbuild/netbsd-x64@0.28.1': optional: true + '@esbuild/netbsd-x64@0.28.2': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -10155,6 +10373,9 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true + '@esbuild/openbsd-arm64@0.28.2': + optional: true + '@esbuild/openbsd-x64@0.18.20': optional: true @@ -10167,6 +10388,9 @@ snapshots: '@esbuild/openbsd-x64@0.28.1': optional: true + '@esbuild/openbsd-x64@0.28.2': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -10176,6 +10400,9 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true + '@esbuild/openharmony-arm64@0.28.2': + optional: true + '@esbuild/sunos-x64@0.18.20': optional: true @@ -10188,6 +10415,9 @@ snapshots: '@esbuild/sunos-x64@0.28.1': optional: true + '@esbuild/sunos-x64@0.28.2': + optional: true + '@esbuild/win32-arm64@0.18.20': optional: true @@ -10200,6 +10430,9 @@ snapshots: '@esbuild/win32-arm64@0.28.1': optional: true + '@esbuild/win32-arm64@0.28.2': + optional: true + '@esbuild/win32-ia32@0.18.20': optional: true @@ -10212,6 +10445,9 @@ snapshots: '@esbuild/win32-ia32@0.28.1': optional: true + '@esbuild/win32-ia32@0.28.2': + optional: true + '@esbuild/win32-x64@0.18.20': optional: true @@ -10224,6 +10460,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@esbuild/win32-x64@0.28.2': + optional: true + '@exodus/bytes@1.15.0(@noble/hashes@2.3.0)': optionalDependencies: '@noble/hashes': 2.3.0 @@ -10385,11 +10624,11 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: glob: 13.0.6 react-docgen-typescript: 2.4.0(typescript@5.9.3) - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) optionalDependencies: typescript: 5.9.3 @@ -12498,10 +12737,10 @@ snapshots: axe-core: 4.12.1 storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7) - '@storybook/addon-docs@10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@storybook/addon-docs@10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7) - '@storybook/csf-plugin': 10.5.8(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@storybook/icons': 2.1.0(react@19.2.7) '@storybook/react-dom-shim': 10.5.8(@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)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7)) react: 19.2.7 @@ -12517,25 +12756,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.5.8(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@storybook/builder-vite@10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: - '@storybook/csf-plugin': 10.5.8(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@storybook/csf-plugin': 10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7) ts-dedent: 2.3.0 - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) transitivePeerDependencies: - esbuild - rollup - webpack - '@storybook/csf-plugin@10.5.8(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@storybook/csf-plugin@10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 rollup: 4.62.4 - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) '@storybook/global@5.0.0': {} @@ -12552,11 +12791,11 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@storybook/react-vite@10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@storybook/react-vite@10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: - '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@joshwooding/vite-plugin-react-docgen-typescript': 0.7.0(typescript@5.9.3)(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@rollup/pluginutils': 5.4.0(rollup@4.62.4) - '@storybook/builder-vite': 10.5.8(esbuild@0.28.1)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@storybook/builder-vite': 10.5.8(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@storybook/react': 10.5.8(@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)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(typescript@5.9.3) empathic: 2.0.1 magic-string: 0.30.21 @@ -12566,7 +12805,7 @@ snapshots: resolve: 1.22.12 storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7) tsconfig-paths: 4.2.0 - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -12659,12 +12898,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.2 - '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 tailwindcss: 4.3.3 - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) '@tanstack/query-core@5.101.4': {} @@ -12988,7 +13227,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -12996,7 +13235,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) transitivePeerDependencies: - supports-color @@ -13026,37 +13265,37 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) - '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) - '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.10(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) - '@vitest/mocker@4.1.11(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))': + '@vitest/mocker@4.1.11(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) '@vitest/pretty-format@3.2.4': dependencies: @@ -13147,7 +13386,7 @@ snapshots: '@agentclientprotocol/sdk': 1.2.1(zod@4.4.3) commander: 15.0.0 skillflag: 0.2.0 - tsx: 4.23.1 + tsx: 4.23.12 zod: 4.4.3 transitivePeerDependencies: - bare-abort-controller @@ -13304,7 +13543,7 @@ snapshots: dependencies: tweetnacl: 0.14.5 - better-auth@1.6.28(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))): + better-auth@1.6.28(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7))(pg@8.18.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))): dependencies: '@better-auth/core': 1.6.28(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@3.25.76))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0) '@better-auth/drizzle-adapter': 1.6.28(@better-auth/core@1.6.28(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@3.25.76))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7)) @@ -13329,7 +13568,7 @@ snapshots: pg: 8.18.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -13954,7 +14193,7 @@ snapshots: '@drizzle-team/brocli': 0.10.2 '@esbuild-kit/esm-loader': 2.6.5 esbuild: 0.25.12 - tsx: 4.23.1 + tsx: 4.23.12 drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.5)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7): optionalDependencies: @@ -14180,6 +14419,35 @@ snapshots: '@esbuild/win32-ia32': 0.28.1 '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} escape-carriage@1.3.1: {} @@ -16825,9 +17093,9 @@ snapshots: tslib@2.8.1: {} - tsx@4.23.1: + tsx@4.23.12: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -17007,7 +17275,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1): + vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.5) @@ -17020,9 +17288,9 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - tsx: 4.23.1 + tsx: 4.23.12 - vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1): + vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.5) @@ -17035,9 +17303,9 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - tsx: 4.23.1 + tsx: 4.23.12 - vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1): + vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.5) @@ -17050,12 +17318,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 lightningcss: 1.32.0 - tsx: 4.23.1 + tsx: 4.23.12 - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -17072,7 +17340,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -17081,10 +17349,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -17101,7 +17369,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -17110,10 +17378,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.10(vite@7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -17130,7 +17398,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 7.3.1(@types/node@25.2.3)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 @@ -17139,10 +17407,10 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)): + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(jsdom@28.1.0(@noble/hashes@2.3.0))(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1)) + '@vitest/mocker': 4.1.11(vite@7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -17159,7 +17427,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1) + vite: 7.3.1(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/server/package.json b/server/package.json index 344e9fea609..67a07dca5eb 100644 --- a/server/package.json +++ b/server/package.json @@ -92,7 +92,7 @@ "@types/ws": "^8.18.1", "cross-env": "^10.1.0", "supertest": "^7.0.0", - "tsx": "^4.23.1", + "tsx": "^4.23.12", "typescript": "^5.7.3", "vite": "^6.4.3", "vitest": "^4.1.10" From 6eb0739ff5b0eafec08e3ea1b32a8c590d3d1c45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:34:15 -0700 Subject: [PATCH 28/39] build(deps-dev): bump tailwindcss from 4.3.2 to 4.3.3 (#11718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.3.2 to 4.3.3.
Release notes

Sourced from tailwindcss's releases.

v4.3.3

Fixed

  • Support --watch --poll[=ms] in @tailwindcss/cli when filesystem events are unreliable or unavailable (#20297)
  • Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. bg-[#fff] and bg-[#FFF] → bg-white) (#20298)
  • Prevent Preflight from overriding Firefox's native iframe:focus-visible outline styles (#20292)
  • Ensure theme('colors.foo') in JS plugins resolves correctly when both --color-foo and --color-foo-bar exist (#20299)
  • Ensure fractional opacity modifiers work with named shadow sizes like shadow-sm/12.5, text-shadow-sm/12.5, drop-shadow-sm/12.5, and inset-shadow-sm/12.5 (#20302)
  • Parse selectors like [data-foo]div as two selectors instead of one (#20303)
  • Ensure @tailwindcss/postcss rebuilds when a preprocessor like Sass changes the input CSS without changing the input file on disk (#20310)
  • Ensure CSS nesting is handled even when Lightning CSS isn't run, such as in @tailwindcss/browser and Tailwind Play (#20124)
  • Prevent achromatic theme colors from shifting hue when mixed in polar color spaces like oklch (#20314)
  • Ensure --spacing(0) is optimized to 0px instead of 0 so it remains a <length> when used in calc(…) (#20319)
  • Load @parcel/watcher only when needed in @tailwindcss/cli --watch mode, so one-off builds and --watch --poll work when @parcel/watcher can't be loaded (#20325)
  • Use explicit platform fonts instead of system-ui and ui-sans-serif so CJK text respects the page's lang attribute on Windows (#20318)
  • Prevent @tailwindcss/upgrade from rewriting ignored files when run from a subdirectory (#20329)
  • Ensure earlier @source rules pointing to nested files are scanned when later @source rules point to files in parent folders (#20335)
  • Prevent @tailwindcss/vite from triggering full page reloads when scanned files are processed by Vite but haven't been loaded as modules yet (#20336)
Changelog

Sourced from tailwindcss's changelog.

[4.3.3] - 2026-07-16

Fixed

  • Support --watch --poll[=ms] in @tailwindcss/cli when filesystem events are unreliable or unavailable (#20297)
  • Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. bg-[#fff] and bg-[#FFF] → bg-white) (#20298)
  • Prevent Preflight from overriding Firefox's native iframe:focus-visible outline styles (#20292)
  • Ensure theme('colors.foo') in JS plugins resolves correctly when both --color-foo and --color-foo-bar exist (#20299)
  • Ensure fractional opacity modifiers work with named shadow sizes like shadow-sm/12.5, text-shadow-sm/12.5, drop-shadow-sm/12.5, and inset-shadow-sm/12.5 (#20302)
  • Parse selectors like [data-foo]div as two selectors instead of one (#20303)
  • Ensure @tailwindcss/postcss rebuilds when a preprocessor like Sass changes the input CSS without changing the input file on disk (#20310)
  • Ensure CSS nesting is handled even when Lightning CSS isn't run, such as in @tailwindcss/browser and Tailwind Play (#20124)
  • Prevent achromatic theme colors from shifting hue when mixed in polar color spaces like oklch (#20314)
  • Ensure --spacing(0) is optimized to 0px instead of 0 so it remains a <length> when used in calc(…) (#20319)
  • Load @parcel/watcher only when needed in @tailwindcss/cli --watch mode, so one-off builds and --watch --poll work when @parcel/watcher can't be loaded (#20325)
  • Use explicit platform fonts instead of system-ui and ui-sans-serif so CJK text respects the page's lang attribute on Windows (#20318)
  • Prevent @tailwindcss/upgrade from rewriting ignored files when run from a subdirectory (#20329)
  • Ensure earlier @source rules pointing to nested files are scanned when later @source rules point to files in parent folders (#20335)
  • Prevent @tailwindcss/vite from triggering full page reloads when scanned files are processed by Vite but haven't been loaded as modules yet (#20336)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tailwindcss&package-manager=npm_and_yarn&previous-version=4.3.2&new-version=4.3.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 15 +++++---------- ui/package.json | 2 +- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2442a88d49..371406c8997 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -953,7 +953,7 @@ importers: version: 1.3.0(@types/react@19.2.17)(react@19.2.7) '@tailwindcss/typography': specifier: ^0.5.20 - version: 0.5.20(tailwindcss@4.3.2) + version: 0.5.20(tailwindcss@4.3.3) '@tanstack/react-query': specifier: ^5.101.4 version: 5.101.4(react@19.2.7) @@ -1040,8 +1040,8 @@ importers: specifier: 10.5.5 version: 10.5.5(@types/react@19.2.17)(react@19.2.7) tailwindcss: - specifier: ^4.3.2 - version: 4.3.2 + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -8366,9 +8366,6 @@ packages: tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} - tailwindcss@4.3.2: - resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} - tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -12893,10 +12890,10 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 - '@tailwindcss/typography@0.5.20(tailwindcss@4.3.2)': + '@tailwindcss/typography@0.5.20(tailwindcss@4.3.3)': dependencies: postcss-selector-parser: 6.0.10 - tailwindcss: 4.3.2 + tailwindcss: 4.3.3 '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': dependencies: @@ -16979,8 +16976,6 @@ snapshots: tailwind-merge@3.6.0: {} - tailwindcss@4.3.2: {} - tailwindcss@4.3.3: {} tapable@2.3.3: {} diff --git a/ui/package.json b/ui/package.json index f7b72eb55e8..388a3b5f276 100644 --- a/ui/package.json +++ b/ui/package.json @@ -79,7 +79,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.3.4", "storybook": "10.5.5", - "tailwindcss": "^4.3.2", + "tailwindcss": "^4.3.3", "typescript": "^5.7.3", "vite": "^6.4.3", "vitest": "^4.1.10" From 81c9fdc5aa3fc20e051f1f197e49a1934191f710 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:36:08 -0700 Subject: [PATCH 29/39] build(deps): bump react-router-dom from 7.18.1 to 7.18.2 (#11715) Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 7.18.1 to 7.18.2.
Changelog

Sourced from react-router-dom's changelog.

v7.18.2

Patch Changes

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=react-router-dom&package-manager=npm_and_yarn&previous-version=7.18.1&new-version=7.18.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 18 +++++++++--------- ui/package.json | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 371406c8997..1bc02c1d461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1003,8 +1003,8 @@ importers: specifier: ^4.12.2 version: 4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-router-dom: - specifier: ^7.18.1 - version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + specifier: ^7.18.2 + version: 7.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -7956,15 +7956,15 @@ packages: react: ^19.2.7 react-dom: ^19.2.7 - react-router-dom@7.18.1: - resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + react-router-dom@7.18.2: + resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} engines: {node: '>=20.0.0'} peerDependencies: react: ^19.2.7 react-dom: ^19.2.7 - react-router@7.18.1: - resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + react-router@7.18.2: + resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} engines: {node: '>=20.0.0'} peerDependencies: react: ^19.2.7 @@ -16427,13 +16427,13 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-router-dom@7.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-router: 7.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) - react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + react-router@7.18.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: cookie: 1.1.1 react: 19.2.7 diff --git a/ui/package.json b/ui/package.json index 388a3b5f276..5dee811fe1f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -65,7 +65,7 @@ "react-i18next": "^17.0.10", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.2", - "react-router-dom": "^7.18.1", + "react-router-dom": "^7.18.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0" }, From 05e89db1d091f8386d2b228570606e4f5e20b5d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:38:23 -0700 Subject: [PATCH 30/39] build(deps): bump react-i18next from 17.0.10 to 17.0.11 (#11711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [//]: # (dependabot-start) ⚠️ **Dependabot is rebasing this PR** ⚠️ Rebasing might not happen immediately, so don't worry if this takes some time. Note: if you make any changes to this PR yourself, they will take precedence over the rebase. --- [//]: # (dependabot-end) Bumps [react-i18next](https://github.com/i18next/react-i18next) from 17.0.10 to 17.0.11.
Changelog

Sourced from react-i18next's changelog.

17.0.11

  • chore: html-parse-stringify updated to ^4.0.1. The parser powering <Trans> is now actively maintained under the i18next org (i18next/html-parse-stringify) after years without upstream releases. 4.x brings modern dual ESM/CJS packaging with an exports map, zero runtime dependencies, reworked TypeScript types and a long list of parser fixes (literal < in text, multiline/CRLF attribute values, comments containing >, doctype handling, quote-aware bracket handling).
  • refactor(Trans): the internal escapeLiteralLessThan scanner (~80 lines) is replaced by the parser's new allowedTags option with identical semantics: only numbered tags, kept basic HTML tags and known component names are parsed as markup, any other tag-shaped sequence in the translation stays literal text. Rendered output is unchanged (all 493 tests pass, including the #1880 and #1893 escaping cases).
Commits
  • 274e2e6 17.0.11
  • 3c4c563 build
  • 1e7ccae chore: html-parse-stringify ^4.0.1 + 17.0.11 changelog
  • 4412e25 html-parse-stringify ^4.0.0: replace escapeLiteralLessThan with the parser's ...
  • 5787979 ci: types-compat falls back to latest when no @​types/react pre-release tag ex...
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=react-i18next&package-manager=npm_and_yarn&previous-version=17.0.10&new-version=17.0.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 26 +++++++++----------------- ui/package.json | 2 +- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bc02c1d461..a627b84a3da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -994,8 +994,8 @@ importers: specifier: ^19.2.7 version: 19.2.7(react@19.2.7) react-i18next: - specifier: ^17.0.10 - version: 17.0.10(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3) + specifier: ^17.0.11 + version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.17)(react@19.2.7) @@ -6817,8 +6817,8 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - html-parse-stringify@3.0.1: - resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -7898,8 +7898,8 @@ packages: peerDependencies: react: ^19.2.7 - react-i18next@17.0.10: - resolution: {integrity: sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==} + react-i18next@17.0.11: + resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==} peerDependencies: i18next: '>= 26.2.0' react: ^19.2.7 @@ -8811,10 +8811,6 @@ packages: jsdom: optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} - vscode-jsonrpc@9.0.1: resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} engines: {node: '>=14.0.0'} @@ -14865,9 +14861,7 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' - html-parse-stringify@3.0.1: - dependencies: - void-elements: 3.1.0 + html-parse-stringify@4.0.1: {} html-url-attributes@3.0.1: {} @@ -16368,10 +16362,10 @@ snapshots: dependencies: react: 19.2.7 - react-i18next@17.0.10(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3): + react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3): dependencies: '@babel/runtime': 7.29.7 - html-parse-stringify: 3.0.1 + html-parse-stringify: 4.0.1 i18next: 26.3.6(typescript@5.9.3) react: 19.2.7 use-sync-external-store: 1.6.0(react@19.2.7) @@ -17431,8 +17425,6 @@ snapshots: transitivePeerDependencies: - msw - void-elements@3.1.0: {} - vscode-jsonrpc@9.0.1: {} w3c-keyname@2.2.8: {} diff --git a/ui/package.json b/ui/package.json index 5dee811fe1f..67ca22cc73a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -62,7 +62,7 @@ "radix-ui": "^1.6.4", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-i18next": "^17.0.10", + "react-i18next": "^17.0.11", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.12.2", "react-router-dom": "^7.18.2", From 61b4fc02d456ed84899cb6d836b91769c39ef3e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:40:11 -0700 Subject: [PATCH 31/39] build(deps-dev): bump @storybook/addon-a11y from 10.5.4 to 10.5.8 (#11713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@storybook/addon-a11y](https://github.com/storybookjs/storybook/tree/HEAD/code/addons/a11y) from 10.5.4 to 10.5.8.
Release notes

Sourced from @​storybook/addon-a11y's releases.

v10.5.8

10.5.8

v10.5.7

10.5.7

v10.5.6

10.5.6

v10.5.5

10.5.5

Changelog

Sourced from @​storybook/addon-a11y's changelog.

10.5.8

10.5.7

10.5.6

10.5.5

Commits
  • 6ef7d1a Bump version from "10.5.7" to "10.5.8" [skip ci]
  • 7c6fb3a Bump version from "10.5.6" to "10.5.7" [skip ci]
  • 80195a0 Merge pull request #35486 from storybookjs/valentin/cli-local-postinstall-aut...
  • 3126f0a Bump version from "10.5.5" to "10.5.6" [skip ci]
  • 05a52b7 Bump version from "10.5.4" to "10.5.5" [skip ci]
  • See full diff in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@storybook/addon-a11y&package-manager=npm_and_yarn&previous-version=10.5.4&new-version=10.5.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 20 ++++++++++---------- ui/package.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a627b84a3da..24add437e53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1013,8 +1013,8 @@ importers: version: 3.6.0 devDependencies: '@storybook/addon-a11y': - specifier: 10.5.4 - version: 10.5.4(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7)) + specifier: 10.5.8 + version: 10.5.8(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7)) '@storybook/addon-docs': specifier: 10.5.8 version: 10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12)) @@ -4896,10 +4896,10 @@ packages: '@stitches/core@1.2.8': resolution: {integrity: sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==} - '@storybook/addon-a11y@10.5.4': - resolution: {integrity: sha512-UWdZtB7Dh1GjqxTPOrisUNDhDGF5pKGzZdzW9DSSHMBcAOx2dsTmXGzLSFprAz4LTT0OHCtKhxNqKK0JZJ0Y8g==} + '@storybook/addon-a11y@10.5.8': + resolution: {integrity: sha512-yunl+sKa29NaUC3I4yjESwk6ED8waEgmmvIJzNAJvdhb7thFzZYYEld68RzKmOVcGBkD3snK5eZ/Xf0l4VWliQ==} peerDependencies: - storybook: ^10.5.4 + storybook: ^10.5.8 '@storybook/addon-docs@10.5.8': resolution: {integrity: sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==} @@ -5571,8 +5571,8 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - axe-core@4.12.1: - resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} b4a@1.8.1: @@ -12724,10 +12724,10 @@ snapshots: '@stitches/core@1.2.8': {} - '@storybook/addon-a11y@10.5.4(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))': + '@storybook/addon-a11y@10.5.8(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))': dependencies: '@storybook/global': 5.0.0 - axe-core: 4.12.1 + axe-core: 4.13.0 storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7) '@storybook/addon-docs@10.5.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.2)(rollup@4.62.4)(storybook@10.5.5(@types/react@19.2.17)(react@19.2.7))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.12))': @@ -13487,7 +13487,7 @@ snapshots: atomic-sleep@1.0.0: {} - axe-core@4.12.1: {} + axe-core@4.13.0: {} b4a@1.8.1: {} diff --git a/ui/package.json b/ui/package.json index 67ca22cc73a..cb8f5d66073 100644 --- a/ui/package.json +++ b/ui/package.json @@ -70,7 +70,7 @@ "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@storybook/addon-a11y": "10.5.4", + "@storybook/addon-a11y": "10.5.8", "@storybook/addon-docs": "10.5.8", "@storybook/react-vite": "10.5.8", "@tailwindcss/vite": "^4.3.3", From e0b64529b3db68bff944b2caf00ccaf195b922cc Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 19 Aug 2026 11:51:31 -0700 Subject: [PATCH 32/39] feat(auth): normalize agent login in the sandbox onto one session table and a capability contract (#11730) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Sandbox agents need a safe login path for each supported adapter > - Codex device login and Claude setup-token login used separate session stores and route logic > - Separate stores made session lookup, expiry, and login capability checks harder to keep consistent > - This pull request unifies both flows on one session table and one capability contract > - The benefit is one company-scoped login model with public session identifiers and shared lifecycle rules ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting (multiple of the above) **Problem or motivation** Codex and Claude sandbox login used separate session stores and different route paths. This split increased the risk of inconsistent company scoping, session lookup, and cleanup. **Proposed solution** Use `adapter_auth_sessions` for both login flows. Use public session identifiers for API access. Select login behavior from projected adapter capability data. Share the route spine, lease arguments, runner lifecycle, and reaper rules. **Alternatives considered** Keep two session tables and add matching fixes to both routes. This keeps duplicate logic and does not provide one capability contract, so this pull request uses shared infrastructure. **Roadmap alignment** This change supports the shipped Cloud / Sandbox agents milestone in `ROADMAP.md`. ## What Changed - Unify Codex device login and Claude setup-token login on `adapter_auth_sessions`. - Return and look up sessions with company-scoped public session identifiers. - Enforce one active session for each company, owner, and adapter. - Share the login route spine, sandbox lease arguments, runner lifecycle, and missing-auth check. - Add a standalone setup-token reaper with adapter-specific row selection. - Add optional login capability projection for adapters and drive route and UI selection from that data. - Rename the provider flag to `supportsLoginPty` and validate its deprecated alias. - Remove the old Claude setup-token session table and add the required migrations. ## Verification - Server typecheck passed with `tsc`. - Database typecheck passed. - UI typecheck passed with `tsc -b`. - Codex login service and route suites passed. - Setup-token session, route, and reaper suites passed. - Adapter session schema, plugin validator, capability projection, UI render, and Daytona suites passed. - GitHub Actions must confirm the complete CI gate after pull request creation. ## Risks - The migrations remove short-lived in-flight login rows during deployment. A login that spans the migration can continue until its provider lease expires. - The Codex credential store remains company-scoped. A cross-owner credential race remains a documented, board-accepted risk. - API clients that use internal session row identifiers no longer work. The API accepts only public session identifiers. ## Model Used Codex, GPT-5, exact runtime model ID not exposed in this handoff, large context window, reasoning, and repository tool use. The implementing engineer produced the code with AI assistance. ## 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/adapter-utils/src/index.ts | 26 + .../src/login-capability.test.ts | 109 + .../adapter-utils/src/login-capability.ts | 169 + .../src/login-runner-lifecycle.test.ts | 151 + .../src/login-runner-lifecycle.ts | 116 + packages/adapter-utils/src/types.ts | 8 + packages/adapters/claude-local/package.json | 1 + .../claude-local/src/server/auth-check.ts | 15 +- .../src/server/setup-token-runner.ts | 73 +- packages/adapters/codex-local/package.json | 1 + .../codex-local/src/server/auth-check.ts | 13 +- .../src/server/device-login-runner.ts | 69 +- .../adapters/codex-local/src/server/index.ts | 2 +- .../src/adapter-auth-sessions-schema.test.ts | 110 +- ...claude-setup-token-sessions-schema.test.ts | 116 - .../0224_unified_adapter_auth_sessions.sql | 18 + .../0225_drop_claude_setup_token_sessions.sql | 5 + .../db/src/migrations/meta/0225_snapshot.json | 39600 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 14 + .../db/src/schema/adapter_auth_sessions.ts | 75 +- .../src/schema/claude_setup_token_sessions.ts | 83 - packages/db/src/schema/index.ts | 5 - .../sandbox-providers/daytona/src/manifest.ts | 18 +- .../src/adapter-auth-check-code.test.ts | 9 + .../shared/src/adapter-auth-check-code.ts | 12 + packages/shared/src/environment-support.ts | 6 +- packages/shared/src/index.ts | 1 + packages/shared/src/types/plugin.ts | 17 +- packages/shared/src/validators/plugin.test.ts | 61 + packages/shared/src/validators/plugin.ts | 37 +- .../agent-device-login-routes.test.ts | 112 +- .../agents-claude-oauth-binding.test.ts | 23 +- .../codex-device-login-reaper.test.ts | 2 +- .../codex-device-login-service.test.ts | 388 +- server/src/adapters/plugin-loader.test.ts | 48 + server/src/adapters/plugin-loader.ts | 16 +- server/src/adapters/registry.test.ts | 37 + server/src/adapters/registry.ts | 45 + server/src/app.ts | 8 +- server/src/index.ts | 37 + .../src/routes/adapter-login-route-spine.ts | 107 + server/src/routes/adapters.test.ts | 70 + server/src/routes/adapters.ts | 39 +- server/src/routes/agents.ts | 194 +- server/src/routes/environments.ts | 2 +- server/src/routes/setup-token-route.test.ts | 62 +- .../src/services/adapter-login-lease.test.ts | 81 + server/src/services/adapter-login-lease.ts | 72 + server/src/services/agents.ts | 1 - .../src/services/codex-device-login-reaper.ts | 17 +- .../services/codex-device-login-service.ts | 160 +- .../src/services/plugin-environment-driver.ts | 4 +- .../src/services/setup-token-reaper.test.ts | 145 + server/src/services/setup-token-reaper.ts | 87 + .../src/services/setup-token-session.test.ts | 153 +- server/src/services/setup-token-session.ts | 337 +- .../setup-token-transport-binding.test.ts | 1 - .../services/setup-token-transport-binding.ts | 54 +- ui/src/api/adapters.ts | 13 + .../AgentConfigForm.render.test.tsx | 154 +- ui/src/components/AgentConfigForm.tsx | 65 +- ui/src/pages/Agents.test.tsx | 4 +- ui/src/pages/NewAgent.test.tsx | 31 +- 63 files changed, 42573 insertions(+), 936 deletions(-) create mode 100644 packages/adapter-utils/src/login-capability.test.ts create mode 100644 packages/adapter-utils/src/login-capability.ts create mode 100644 packages/adapter-utils/src/login-runner-lifecycle.test.ts create mode 100644 packages/adapter-utils/src/login-runner-lifecycle.ts delete mode 100644 packages/db/src/claude-setup-token-sessions-schema.test.ts create mode 100644 packages/db/src/migrations/0224_unified_adapter_auth_sessions.sql create mode 100644 packages/db/src/migrations/0225_drop_claude_setup_token_sessions.sql create mode 100644 packages/db/src/migrations/meta/0225_snapshot.json delete mode 100644 packages/db/src/schema/claude_setup_token_sessions.ts create mode 100644 packages/shared/src/adapter-auth-check-code.test.ts create mode 100644 packages/shared/src/adapter-auth-check-code.ts create mode 100644 server/src/adapters/plugin-loader.test.ts create mode 100644 server/src/adapters/registry.test.ts create mode 100644 server/src/routes/adapter-login-route-spine.ts create mode 100644 server/src/routes/adapters.test.ts create mode 100644 server/src/services/adapter-login-lease.test.ts create mode 100644 server/src/services/adapter-login-lease.ts create mode 100644 server/src/services/setup-token-reaper.test.ts create mode 100644 server/src/services/setup-token-reaper.ts diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index ebe6409e708..aa7bb230299 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -84,6 +84,32 @@ export type { RuntimeStatusUpdate, } from "./runtime-progress.js"; export { inferOpenAiCompatibleBiller } from "./billing.js"; +export { + ADAPTER_LOGIN_PANEL_MODES, + ADAPTER_LOGIN_SANDBOX_TRANSPORTS, + ADAPTER_LOGIN_TIMEOUT_POLICIES, + ADAPTER_LOGIN_COMPLETION_CLAIMS, + assertValidAdapterLoginCapability, + validateAdapterLoginCapability, +} from "./login-capability.js"; +export type { + AdapterLoginPanelMode, + AdapterLoginSandboxTransport, + AdapterLoginTimeoutPolicy, + AdapterLoginCompletionClaim, + AdapterLoginPrompt, + AdapterLoginCompletionContext, + AdapterLoginCapability, +} from "./login-capability.js"; +export { raceLoginRunnerExit } from "./login-runner-lifecycle.js"; +export type { + LoginRunnerOutcome, + LoginRunnerResult, + LoginRunnerLog, + LoginRunnerLifecycleOptions, + LoginRunnerDisposable, + LoginRunnerRaceResult, +} from "./login-runner-lifecycle.js"; // Keep the root adapter-utils entry browser-safe because the UI imports it. // The sandbox callback bridge stays available via its dedicated subpath export. export type { diff --git a/packages/adapter-utils/src/login-capability.test.ts b/packages/adapter-utils/src/login-capability.test.ts new file mode 100644 index 00000000000..a79a4e2dfa1 --- /dev/null +++ b/packages/adapter-utils/src/login-capability.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { + assertValidAdapterLoginCapability, + validateAdapterLoginCapability, + type AdapterLoginCapability, +} from "./login-capability.js"; + +// A well-formed login capability. Each test starts from a clone of this value +// and then breaks one field, so each case checks exactly one rule. +function validCapability(): AdapterLoginCapability { + return { + panelMode: "displayed_code", + sandboxTransport: "streamed_exec", + timeoutPolicy: "caller_bounded", + getCommand: () => "vendor login", + parsePrompt: () => null, + }; +} + +describe("assertValidAdapterLoginCapability", () => { + it("accepts a well-formed capability with only the required fields", () => { + expect(() => assertValidAdapterLoginCapability(validCapability(), "vendor")).not.toThrow(); + }); + + it("accepts a well-formed capability with every optional member set", () => { + const capability: AdapterLoginCapability = { + panelMode: "submitted_browser_code", + sandboxTransport: "pseudo_terminal", + timeoutPolicy: "fixed", + getCommand: () => "vendor setup-token", + parsePrompt: () => null, + captureCredential: () => null, + onComplete: async () => {}, + completionClaim: "storedSessionId", + }; + expect(() => assertValidAdapterLoginCapability(capability, "vendor")).not.toThrow(); + }); + + it("rejects a non-object capability", () => { + expect(() => assertValidAdapterLoginCapability(null, "vendor")).toThrow(/must be an object/); + expect(() => assertValidAdapterLoginCapability("displayed_code", "vendor")).toThrow( + /must be an object/, + ); + }); + + it("rejects an unknown panelMode", () => { + const bad = { ...validCapability(), panelMode: "hidden_code" }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/panelMode/); + }); + + it("rejects an unknown sandboxTransport", () => { + const bad = { ...validCapability(), sandboxTransport: "http" }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/sandboxTransport/); + }); + + it("rejects an unknown timeoutPolicy", () => { + const bad = { ...validCapability(), timeoutPolicy: "unbounded" }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/timeoutPolicy/); + }); + + it("rejects a missing getCommand", () => { + const bad = { ...validCapability(), getCommand: "vendor login" }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/getCommand/); + }); + + it("rejects a missing parsePrompt", () => { + const bad = { ...validCapability(), parsePrompt: undefined }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/parsePrompt/); + }); + + it("rejects a non-function captureCredential", () => { + const bad = { ...validCapability(), captureCredential: "token" }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/captureCredential/); + }); + + it("rejects a non-function onComplete", () => { + const bad = { ...validCapability(), onComplete: true }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/onComplete/); + }); + + it("rejects an unknown completionClaim", () => { + const bad = { ...validCapability(), completionClaim: "storedToken" }; + expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/completionClaim/); + }); + + it("names the adapter in the error text", () => { + const bad = { ...validCapability(), panelMode: "hidden_code" }; + expect(() => assertValidAdapterLoginCapability(bad, "codex_local")).toThrow(/codex_local/); + }); +}); + +describe("validateAdapterLoginCapability", () => { + it("accepts a module with no login capability", () => { + expect(() => validateAdapterLoginCapability({ type: "vendor_local" })).not.toThrow(); + }); + + it("accepts a module with a well-formed login capability", () => { + expect(() => + validateAdapterLoginCapability({ type: "vendor_local", loginCapability: validCapability() }), + ).not.toThrow(); + }); + + it("rejects a module with a malformed login capability", () => { + const bad = { ...validCapability(), panelMode: "hidden_code" }; + expect(() => + validateAdapterLoginCapability({ type: "vendor_local", loginCapability: bad }), + ).toThrow(/vendor_local/); + }); +}); diff --git a/packages/adapter-utils/src/login-capability.ts b/packages/adapter-utils/src/login-capability.ts new file mode 100644 index 00000000000..986dc8a6b6e --- /dev/null +++ b/packages/adapter-utils/src/login-capability.ts @@ -0,0 +1,169 @@ +// The adapter login capability. An adapter declares this optional capability to +// describe how the server drives an interactive sandbox login. The module holds +// the capability types, the fixed value sets, and one runtime validator. The +// validator fails closed: it rejects a malformed capability shape with a clear +// error, so the loader never accepts a partial capability. +// +// Security (secret handling): the capability data holds no secret. The scalar +// fields carry only a fixed, non-secret value. The function members are code, +// not data; a member can return a runtime secret (for example a minted token), +// but the capability object never stores a secret. API-key-only vendors declare +// no login capability. + +/** + * The login panel mode. `displayed_code` shows a one-time code that the user + * enters in the browser. `submitted_browser_code` asks the user to paste a code + * from the browser back into the login flow. + */ +export const ADAPTER_LOGIN_PANEL_MODES = ["displayed_code", "submitted_browser_code"] as const; +export type AdapterLoginPanelMode = (typeof ADAPTER_LOGIN_PANEL_MODES)[number]; + +/** + * The sandbox transport that the login command needs. `streamed_exec` runs the + * command over the streamed exec channel. `pseudo_terminal` runs the command on + * a real pseudo-terminal, because a pipe emits no login prompt. + */ +export const ADAPTER_LOGIN_SANDBOX_TRANSPORTS = ["streamed_exec", "pseudo_terminal"] as const; +export type AdapterLoginSandboxTransport = (typeof ADAPTER_LOGIN_SANDBOX_TRANSPORTS)[number]; + +/** + * The host-side timeout policy. `caller_bounded` lets the caller set the + * timeout. `fixed` binds the timeout to a fixed adapter value. + */ +export const ADAPTER_LOGIN_TIMEOUT_POLICIES = ["caller_bounded", "fixed"] as const; +export type AdapterLoginTimeoutPolicy = (typeof ADAPTER_LOGIN_TIMEOUT_POLICIES)[number]; + +/** + * The optional completion claim that the login flow records on success. + * `storedSessionId` marks that the flow stored a session identifier. + */ +export const ADAPTER_LOGIN_COMPLETION_CLAIMS = ["storedSessionId"] as const; +export type AdapterLoginCompletionClaim = (typeof ADAPTER_LOGIN_COMPLETION_CLAIMS)[number]; + +/** + * The normalized login prompt. `url` is the validated authorization URL. `code` + * is the one-time code to show, present only in `displayed_code` mode. A prompt + * carries no credential secret. + */ +export interface AdapterLoginPrompt { + url: string; + code?: string; +} + +/** + * The completion context. The server passes it to the completion hook. It + * carries the non-secret completion state. It carries no credential byte. + */ +export interface AdapterLoginCompletionContext { + /** The session identifier that the flow stored, when it stored one. */ + storedSessionId?: string; +} + +/** + * The optional adapter login capability. It declares how the server drives an + * interactive sandbox login for the adapter. The capability data holds no + * secret. An adapter with no interactive login (for example an API-key-only + * vendor) declares no capability. + */ +export interface AdapterLoginCapability { + /** The login panel mode. */ + panelMode: AdapterLoginPanelMode; + /** The sandbox transport that the login command needs. */ + sandboxTransport: AdapterLoginSandboxTransport; + /** The host-side timeout policy. */ + timeoutPolicy: AdapterLoginTimeoutPolicy; + /** Returns the fixed, non-secret login command. */ + getCommand: () => string; + /** + * Parses the authorization prompt from the login output. Returns null when the + * output holds no prompt yet. The parser keeps every input byte out of its + * result and out of every thrown error. + */ + parsePrompt: (output: string) => AdapterLoginPrompt | null; + /** + * Captures the minted credential from the login output. Returns the raw + * credential bytes, or null when the output holds no credential yet. Only a + * flow that prints the credential to the terminal sets it. The result is a + * runtime secret; the capability data never stores it. + */ + captureCredential?: (output: string) => Buffer | null; + /** + * Runs after a successful login. It records the non-secret completion state. + * It carries no credential byte. + */ + onComplete?: (ctx: AdapterLoginCompletionContext) => Promise; + /** The optional completion claim that the flow records on success. */ + completionClaim?: AdapterLoginCompletionClaim; +} + +function isOneOf(values: T, candidate: unknown): candidate is T[number] { + return typeof candidate === "string" && (values as readonly string[]).includes(candidate); +} + +/** + * Validates one login capability. The function fails closed: it throws a clear + * error for a malformed shape. It checks each scalar field against its fixed + * value set, checks each required function member, and checks each optional + * member only when the member is present. `adapterType` names the adapter in the + * error text. + */ +export function assertValidAdapterLoginCapability( + value: unknown, + adapterType: string, +): asserts value is AdapterLoginCapability { + const prefix = `Adapter "${adapterType}" declares an invalid login capability`; + if (typeof value !== "object" || value === null) { + throw new Error(`${prefix}: the capability must be an object.`); + } + const cap = value as Record; + + if (!isOneOf(ADAPTER_LOGIN_PANEL_MODES, cap.panelMode)) { + throw new Error( + `${prefix}: "panelMode" must be one of ${ADAPTER_LOGIN_PANEL_MODES.join(", ")}.`, + ); + } + if (!isOneOf(ADAPTER_LOGIN_SANDBOX_TRANSPORTS, cap.sandboxTransport)) { + throw new Error( + `${prefix}: "sandboxTransport" must be one of ${ADAPTER_LOGIN_SANDBOX_TRANSPORTS.join(", ")}.`, + ); + } + if (!isOneOf(ADAPTER_LOGIN_TIMEOUT_POLICIES, cap.timeoutPolicy)) { + throw new Error( + `${prefix}: "timeoutPolicy" must be one of ${ADAPTER_LOGIN_TIMEOUT_POLICIES.join(", ")}.`, + ); + } + if (typeof cap.getCommand !== "function") { + throw new Error(`${prefix}: "getCommand" must be a function.`); + } + if (typeof cap.parsePrompt !== "function") { + throw new Error(`${prefix}: "parsePrompt" must be a function.`); + } + if (cap.captureCredential !== undefined && typeof cap.captureCredential !== "function") { + throw new Error(`${prefix}: "captureCredential" must be a function when present.`); + } + if (cap.onComplete !== undefined && typeof cap.onComplete !== "function") { + throw new Error(`${prefix}: "onComplete" must be a function when present.`); + } + if ( + cap.completionClaim !== undefined && + !isOneOf(ADAPTER_LOGIN_COMPLETION_CLAIMS, cap.completionClaim) + ) { + throw new Error( + `${prefix}: "completionClaim" must be one of ${ADAPTER_LOGIN_COMPLETION_CLAIMS.join(", ")} when present.`, + ); + } +} + +/** + * Validates the optional login capability of an adapter module. The function is + * a no-op when the module declares no login capability. It throws a clear error + * when the module declares a malformed capability, so the loader fails closed. + */ +export function validateAdapterLoginCapability(mod: { + type?: unknown; + loginCapability?: unknown; +}): void { + if (mod.loginCapability === undefined) return; + const adapterType = typeof mod.type === "string" && mod.type.length > 0 ? mod.type : "unknown"; + assertValidAdapterLoginCapability(mod.loginCapability, adapterType); +} diff --git a/packages/adapter-utils/src/login-runner-lifecycle.test.ts b/packages/adapter-utils/src/login-runner-lifecycle.test.ts new file mode 100644 index 00000000000..1f87b6dcf41 --- /dev/null +++ b/packages/adapter-utils/src/login-runner-lifecycle.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it, vi } from "vitest"; +import { + raceLoginRunnerExit, + type LoginRunnerDisposable, + type LoginRunnerOutcome, + type LoginRunnerRaceResult, + type LoginRunnerResult, +} from "./login-runner-lifecycle.js"; + +describe("LoginRunnerOutcome", () => { + it("names the four terminal outcomes", () => { + const outcomes: LoginRunnerOutcome[] = ["success", "failure", "timeout", "cancelled"]; + expect(new Set(outcomes).size).toBe(4); + }); +}); + +describe("LoginRunnerResult", () => { + it("carries the fixed status fields", () => { + const result: LoginRunnerResult = { outcome: "success", exitCode: 0, promptSurfaced: true }; + expect(result).toEqual({ outcome: "success", exitCode: 0, promptSurfaced: true }); + }); +}); + +describe("raceLoginRunnerExit", () => { + it("resolves with the exit kind when the work resolves first", async () => { + const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: 0 }), 10_000, undefined); + expect(raced).toEqual({ kind: "exit", exitCode: 0 }); + }); + + it("passes a non-zero exit code through", async () => { + const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: 7 }), 10_000, undefined); + expect(raced).toEqual({ kind: "exit", exitCode: 7 }); + }); + + it("passes a null exit code through", async () => { + const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: null }), 10_000, undefined); + expect(raced).toEqual({ kind: "exit", exitCode: null }); + }); + + it("resolves with the timeout kind when the work hangs past the timeout", async () => { + const hang = new Promise<{ exitCode: number | null }>(() => {}); + const raced = await raceLoginRunnerExit(hang, 5, undefined); + expect(raced).toEqual({ kind: "timeout" }); + }); + + it("resolves with the cancelled kind when the signal aborts", async () => { + const hang = new Promise<{ exitCode: number | null }>(() => {}); + const controller = new AbortController(); + const racePromise = raceLoginRunnerExit(hang, 10_000, controller.signal); + controller.abort(); + expect(await racePromise).toEqual({ kind: "cancelled" }); + }); + + it("resolves with the cancelled kind when the signal is already aborted", async () => { + const hang = new Promise<{ exitCode: number | null }>(() => {}); + const controller = new AbortController(); + controller.abort(); + const raced = await raceLoginRunnerExit(hang, 10_000, controller.signal); + expect(raced).toEqual({ kind: "cancelled" }); + }); + + it("returns cancelled for a pre-aborted signal even when the work can resolve", async () => { + const controller = new AbortController(); + controller.abort(); + const raced = await raceLoginRunnerExit( + Promise.resolve({ exitCode: 0 }), + 10_000, + controller.signal, + ); + expect(raced).toEqual({ kind: "cancelled" }); + }); + + it("consumes a late work rejection after a pre-aborted signal cancels the race", async () => { + let rejectWork: (error: Error) => void = () => {}; + const work = new Promise<{ exitCode: number | null }>((_resolve, reject) => { + rejectWork = reject; + }); + const controller = new AbortController(); + controller.abort(); + const raced = await raceLoginRunnerExit(work, 10_000, controller.signal); + expect(raced).toEqual({ kind: "cancelled" }); + // The pre-aborted signal already cancelled the race. The work then rejects. + // The helper consumes the late rejection, so it never becomes an unhandled + // rejection. + expect(() => rejectWork(new Error("late"))).not.toThrow(); + await Promise.resolve(); + }); + + it("rejects when the work rejects", async () => { + const error = new Error("the driver errored"); + await expect(raceLoginRunnerExit(Promise.reject(error), 10_000, undefined)).rejects.toBe(error); + }); + + it("does not double-settle when the signal aborts after the exit", async () => { + const controller = new AbortController(); + const raced = await raceLoginRunnerExit( + Promise.resolve({ exitCode: 0 }), + 10_000, + controller.signal, + ); + expect(raced).toEqual({ kind: "exit", exitCode: 0 }); + // The race already settled. A late abort must not throw or change the result. + expect(() => controller.abort()).not.toThrow(); + }); + + it("removes the abort listener after it settles on the exit", async () => { + const controller = new AbortController(); + const removeSpy = vi.spyOn(controller.signal, "removeEventListener"); + await raceLoginRunnerExit(Promise.resolve({ exitCode: 0 }), 10_000, controller.signal); + expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + it("consumes a late work rejection after the timeout settles the race", async () => { + let rejectWork: (error: Error) => void = () => {}; + const work = new Promise<{ exitCode: number | null }>((_resolve, reject) => { + rejectWork = reject; + }); + const raced = await raceLoginRunnerExit(work, 5, undefined); + expect(raced).toEqual({ kind: "timeout" }); + // The race already settled on the timeout. The work then rejects. The helper + // consumes the late rejection, so it never becomes an unhandled rejection. + expect(() => rejectWork(new Error("late"))).not.toThrow(); + await Promise.resolve(); + }); +}); + +describe("login-runner lifecycle ordering", () => { + it("disposes a driver one time", async () => { + const dispose = vi.fn(async () => {}); + const driver: LoginRunnerDisposable = { dispose }; + await driver.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it("races to a terminal state, then disposes the driver", async () => { + const events: string[] = []; + const driver: LoginRunnerDisposable = { + dispose: vi.fn(async () => { + events.push("dispose"); + }), + }; + const work = Promise.resolve({ exitCode: 0 }).then((value) => { + events.push("exit"); + return value; + }); + const raced = await raceLoginRunnerExit(work, 10_000, undefined); + await driver.dispose(); + expect(raced).toEqual({ kind: "exit", exitCode: 0 }); + expect(events).toEqual(["exit", "dispose"]); + }); +}); diff --git a/packages/adapter-utils/src/login-runner-lifecycle.ts b/packages/adapter-utils/src/login-runner-lifecycle.ts new file mode 100644 index 00000000000..7dbd36d0799 --- /dev/null +++ b/packages/adapter-utils/src/login-runner-lifecycle.ts @@ -0,0 +1,116 @@ +// The shared login-runner lifecycle contract. Both login runners use these +// types: the Codex device-login runner and the Claude setup-token runner. The +// module defines the runner outcome union, the result base type, the fixed-log +// callback type, the timeout and cancellation options, the dispose contract, and +// the timeout race helper. The module holds types and one pure helper only. It +// does not change runner behavior. +// +// Security (secret handling): the outcome and the result carry only a fixed, +// non-secret status. They never carry a URL, a code, or a token byte. The log +// callback receives only fixed status lines. Each runner keeps this rule. + +/** + * The terminal outcome of a login runner. A runner reports exactly one value. + * The value is a fixed, non-secret status. + */ +export type LoginRunnerOutcome = "success" | "failure" | "timeout" | "cancelled"; + +/** + * The base result of a login runner. Every runner returns at least these three + * fields. A runner can extend the base with its own non-secret status fields. The + * result never carries a URL, a code, or a token byte. + */ +export interface LoginRunnerResult { + outcome: LoginRunnerOutcome; + exitCode: number | null; + promptSurfaced: boolean; +} + +/** + * A non-leaking progress sink. It receives only fixed status lines. A runner + * never passes a URL, a code, or a token byte to this sink. + */ +export type LoginRunnerLog = (line: string) => void; + +/** + * The shared lifecycle options. Both runners set the host-side timeout, accept an + * optional cancellation signal, and accept an optional log sink. Each runner + * option type extends this base with its own callbacks. + */ +export interface LoginRunnerLifecycleOptions { + /** 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?: LoginRunnerLog; +} + +/** + * The dispose contract. A login driver releases its resources through `dispose`. + * The runner always disposes the driver one time for every terminal state. The + * method must be safe to call one time on every path. + */ +export interface LoginRunnerDisposable { + /** Releases the driver resources. */ + dispose(): Promise; +} + +/** + * The result of the timeout race. The `exit` kind carries the command exit code. + * The `timeout` kind and the `cancelled` kind each mark a terminal state with no + * exit code. + */ +export type LoginRunnerRaceResult = + | { kind: "exit"; exitCode: number | null } + | { kind: "timeout" } + | { kind: "cancelled" }; + +/** + * Races the streaming `work` against the timeout and the cancellation signal. The + * work result resolves the race with an `exit` status; the timeout resolves it + * with a `timeout` status; the signal resolves it with a `cancelled` status. A + * pre-aborted signal resolves the race with a `cancelled` status at once, before + * the helper starts the timer or listens to the work. A work error rejects the + * race, so the caller can convert it to a fixed, non-secret error. A late work + * rejection after the race already settled is consumed here, so it never becomes + * an unhandled rejection. The helper clears the timer and removes the signal + * listener on the first settle. + */ +export function raceLoginRunnerExit( + work: Promise<{ exitCode: number | null }>, + timeoutMs: number, + signal: AbortSignal | undefined, +): Promise { + return new Promise((resolve, reject) => { + // The signal is already aborted. Resolve with the cancelled status now. The + // "abort" event does not fire again for an already-aborted signal, so a late + // listener never runs. The helper still consumes a late work rejection below. + if (signal?.aborted) { + work.then( + () => {}, + () => {}, + ); + resolve({ kind: "cancelled" }); + return; + } + 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 }); + work.then( + (value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })), + (error) => finish(() => reject(error)), + ); + }); +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 3957bbbce34..eb93a453315 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -500,6 +500,14 @@ export interface ServerAdapterModule { * and provisioned in fresh remote environments such as sandboxes. */ getRuntimeCommandSpec?: (config: Record) => AdapterRuntimeCommandSpec | null; + + /** + * Optional: declare the interactive sandbox login capability. The server uses + * it to drive the login flow and to project the safe panel fields to the user + * interface. An adapter with no interactive login (for example an + * API-key-only vendor) omits it. The capability data holds no secret. + */ + loginCapability?: import("./login-capability.js").AdapterLoginCapability; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index 8c83a2f4a29..0f8553a2e65 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -55,6 +55,7 @@ "dependencies": { "@agentclientprotocol/claude-agent-acp": "^0.66.0", "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/packages/adapters/claude-local/src/server/auth-check.ts b/packages/adapters/claude-local/src/server/auth-check.ts index 9e606eb1493..b165f91deef 100644 --- a/packages/adapters/claude-local/src/server/auth-check.ts +++ b/packages/adapters/claude-local/src/server/auth-check.ts @@ -1,12 +1,7 @@ /** - * The canonical check code the Claude Test engine emits when a sandbox target - * has no ready authentication. The user interface reads this stable code to - * decide login eligibility. The user interface does not read the message text - * or the top-level status. - * - * The name is neutral. It carries no vendor name and no login-flow word, because - * a Test result can sync to public packages. The value matches the code the - * Codex Test engine emits, so the user interface can gate login from one code - * across adapters. + * The canonical check code the Claude Test engine emits when a sandbox target has + * no ready authentication. This module re-exports the one shared constant, so + * the adapters and the user interface gate login from the same code. See the + * source in the shared package for the full contract. */ -export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing"; +export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; diff --git a/packages/adapters/claude-local/src/server/setup-token-runner.ts b/packages/adapters/claude-local/src/server/setup-token-runner.ts index 17521610b09..076323f7f6b 100644 --- a/packages/adapters/claude-local/src/server/setup-token-runner.ts +++ b/packages/adapters/claude-local/src/server/setup-token-runner.ts @@ -1,3 +1,10 @@ +import { + raceLoginRunnerExit, + type LoginRunnerDisposable, + type LoginRunnerLifecycleOptions, + type LoginRunnerOutcome, + type LoginRunnerResult, +} from "@paperclipai/adapter-utils"; import { parseSetupTokenCredential, parseSetupTokenPrompt, @@ -69,7 +76,7 @@ export const CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS = 64 * 1024; * directly; a caller injects a concrete driver. A production driver binds these * methods to a PTY child that runs {@link CLAUDE_SETUP_TOKEN_COMMAND}. */ -export interface SetupTokenPtyDriver { +export interface SetupTokenPtyDriver extends LoginRunnerDisposable { /** * Starts `command` in a PTY and streams the terminal output to `onData` in * memory. Resolves with the child exit code when the child ends. A driver must @@ -87,8 +94,6 @@ export interface SetupTokenPtyDriver { * and safe to call more than one time. */ stop(): void; - /** Releases the driver resources. */ - dispose(): Promise; } /** Receives the parsed prompt one time in memory. The caller displays it. */ @@ -110,19 +115,20 @@ export type SetupTokenCodeProvider = (signal: AbortSignal) => Promise; */ export type SetupTokenCredentialSink = (authBytes: Buffer) => Promise; -export type SetupTokenOutcome = "success" | "failure" | "timeout" | "cancelled"; +/** The setup-token runner outcome. It is a fixed, non-secret status. */ +export type SetupTokenOutcome = LoginRunnerOutcome; -/** The runner result. It never carries a URL, a code, or a token byte. */ -export interface SetupTokenLoginResult { - outcome: SetupTokenOutcome; - exitCode: number | null; - promptSurfaced: boolean; +/** + * The runner result. It never carries a URL, a code, or a token byte. It extends + * the base result with the two setup-token status fields. + */ +export interface SetupTokenLoginResult extends LoginRunnerResult { codeSubmitted: boolean; /** True when the runner bound the token and invoked `onCredential` one time. */ credentialDelivered: boolean; } -export interface RunSetupTokenLoginOptions { +export interface RunSetupTokenLoginOptions extends LoginRunnerLifecycleOptions { /** The login command. Defaults to {@link CLAUDE_SETUP_TOKEN_COMMAND}. */ command?: string; /** Receives the parsed prompt one time in memory. The caller displays it. */ @@ -141,12 +147,6 @@ export interface RunSetupTokenLoginOptions { * write. Defaults to {@link CODE_SUBMIT_SETTLE_MS}. A test sets it to 0. */ codeSubmitSettleMs?: number; - /** 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; } /** @@ -168,45 +168,6 @@ function settleDelay(ms: number, signal: AbortSignal): Promise { }); } -type RaceResult = - | { kind: "exit"; exitCode: number | null } - | { kind: "timeout" } - | { kind: "cancelled" }; - -/** - * Races the streaming start against the timeout and the cancellation signal. The - * start 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 start rejection after the race - * already settled is consumed here, so it never becomes an unhandled rejection. - */ -function raceStart( - start: Promise<{ exitCode: number | null }>, - timeoutMs: number, - signal: AbortSignal, -): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const cleanup = () => { - clearTimeout(timer); - 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" })); - signal.addEventListener("abort", onAbort, { once: true }); - start.then( - (value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })), - (error) => finish(() => reject(error)), - ); - }); -} - /** * Stops the child and releases the driver for a terminal state. The runner calls * this on every exit path: a normal exit, a non-zero exit, a timeout, a @@ -394,7 +355,7 @@ export async function runSetupTokenLogin( } const start = driver.start(command, onData); - const raced = await raceStart(start, timeoutMs, controller.signal); + const raced = await raceLoginRunnerExit(start, timeoutMs, controller.signal); // Release a pending code-input routine, then let it settle. The routine is // self-guarding, so this await never rejects. controller.abort(); diff --git a/packages/adapters/codex-local/package.json b/packages/adapters/codex-local/package.json index 68c05ccf8c3..5f51cdba2a2 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -54,6 +54,7 @@ "dependencies": { "@agentclientprotocol/codex-acp": "^1.2.0", "@paperclipai/adapter-utils": "workspace:*", + "@paperclipai/shared": "workspace:*", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/packages/adapters/codex-local/src/server/auth-check.ts b/packages/adapters/codex-local/src/server/auth-check.ts index 3882a730110..e157c88a234 100644 --- a/packages/adapters/codex-local/src/server/auth-check.ts +++ b/packages/adapters/codex-local/src/server/auth-check.ts @@ -1,10 +1,7 @@ /** - * The canonical check code both Codex Test engines emit when a sandbox target - * has no ready authentication. The user interface reads this stable code to - * decide login eligibility. The user interface does not read the message text - * or the top-level status. - * - * The name is neutral. It carries no vendor name and no login-flow word, because - * a Test result can sync to public packages. + * The canonical check code the Codex Test engine emits when a sandbox target has + * no ready authentication. This module re-exports the one shared constant, so + * the adapters and the user interface gate login from the same code. See the + * source in the shared package for the full contract. */ -export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing"; +export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; diff --git a/packages/adapters/codex-local/src/server/device-login-runner.ts b/packages/adapters/codex-local/src/server/device-login-runner.ts index 2eccde880bf..4c66a702c0e 100644 --- a/packages/adapters/codex-local/src/server/device-login-runner.ts +++ b/packages/adapters/codex-local/src/server/device-login-runner.ts @@ -1,3 +1,10 @@ +import { + raceLoginRunnerExit, + type LoginRunnerDisposable, + type LoginRunnerLifecycleOptions, + type LoginRunnerOutcome, + type LoginRunnerResult, +} from "@paperclipai/adapter-utils"; import { parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js"; // The device-login runner. It runs the Codex device-login command through an @@ -35,7 +42,7 @@ const MAX_PARSE_BUFFER_CHARS = 64 * 1024; * three methods to a non-persisting Daytona exec path, a file read, and a * sandbox delete. */ -export interface SandboxLoginDriver { +export interface SandboxLoginDriver extends LoginRunnerDisposable { /** * 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 @@ -44,23 +51,18 @@ export interface SandboxLoginDriver { 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 device-login runner outcome. It is a fixed, non-secret status. */ +export type DeviceLoginOutcome = LoginRunnerOutcome; /** 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 type DeviceLoginResult = LoginRunnerResult; -export interface RunDeviceLoginOptions { +export interface RunDeviceLoginOptions extends LoginRunnerLifecycleOptions { /** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */ command?: string; /** Receives the parsed prompt one time in memory. The caller displays it. */ @@ -73,51 +75,6 @@ export interface RunDeviceLoginOptions { 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)), - ); - }); } /** @@ -167,7 +124,7 @@ export async function runDeviceLogin( } const exec = driver.execStreaming(command, onStdout); - const raced = await raceExec(exec, timeoutMs, signal); + const raced = await raceLoginRunnerExit(exec, timeoutMs, signal); if (raced.kind === "timeout") { log("[paperclip] Device login timed out; disposing the sandbox."); diff --git a/packages/adapters/codex-local/src/server/index.ts b/packages/adapters/codex-local/src/server/index.ts index 06d14ec968e..9db6ea0bb1a 100644 --- a/packages/adapters/codex-local/src/server/index.ts +++ b/packages/adapters/codex-local/src/server/index.ts @@ -32,7 +32,7 @@ export { type DeviceLoginResult, type RunDeviceLoginOptions, } from "./device-login-runner.js"; -export { DEVICE_LOGIN_URL, type DeviceLoginPrompt } from "./device-login-parse.js"; +export { DEVICE_LOGIN_URL, parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js"; export { promoteDeviceLoginCredential, checkStagedCredentialReadiness, diff --git a/packages/db/src/adapter-auth-sessions-schema.test.ts b/packages/db/src/adapter-auth-sessions-schema.test.ts index cfa7e58ceef..d0375816757 100644 --- a/packages/db/src/adapter-auth-sessions-schema.test.ts +++ b/packages/db/src/adapter-auth-sessions-schema.test.ts @@ -1,10 +1,15 @@ import fs from "node:fs"; import { getTableConfig } from "drizzle-orm/pg-core"; import { describe, expect, it } from "vitest"; -import { adapterAuthSessions } from "./schema/adapter_auth_sessions.js"; +import { + ADAPTER_AUTH_SESSION_ACTIVE_STATES, + adapterAuthSessions, +} from "./schema/adapter_auth_sessions.js"; type PgTable = Parameters[0]; +const ACTIVE_INDEX = "adapter_auth_sessions_company_owner_adapter_active_uq"; + function findIndex(table: PgTable, indexName: string) { return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName); } @@ -15,53 +20,114 @@ function indexColumns(table: PgTable, indexName: string): string[] { return index.config.columns.map((column) => (column as { name: string }).name); } -function columnIsNotNull(table: PgTable, columnName: string): boolean { - const column = getTableConfig(table).columns.find((candidate) => candidate.name === columnName); - return column?.notNull ?? false; +function column(table: PgTable, columnName: string) { + return getTableConfig(table).columns.find((candidate) => candidate.name === columnName); } -// The migration that creates the table. The test locates it by content, so a -// later re-generation with a different name does not break the test. +function columnIsNotNull(table: PgTable, columnName: string): boolean { + return column(table, columnName)?.notNull ?? false; +} + +// The migration that re-scopes the active index. The test locates it by content, +// so a later re-generation with a different name does not break the test. function activeIndexMigrationSql(): string { const migrationsDir = new URL("./migrations/", import.meta.url); const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")); for (const name of files) { const content = fs.readFileSync(new URL(name, migrationsDir), "utf8"); - if (content.includes("adapter_auth_sessions_company_adapter_active_uq")) { + if (content.includes(ACTIVE_INDEX)) { return content; } } - throw new Error("no migration creates adapter_auth_sessions_company_adapter_active_uq"); + throw new Error(`no migration creates ${ACTIVE_INDEX}`); } describe("adapter auth sessions schema", () => { - it("serializes on the company credential slot over the active statuses", () => { - const active = findIndex(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq"); + it("serializes the active slot on company, owner, and adapter", () => { + const active = findIndex(adapterAuthSessions, ACTIVE_INDEX); expect(active).toBeDefined(); expect(active?.config.unique).toBe(true); - expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq")).toEqual([ + expect(indexColumns(adapterAuthSessions, ACTIVE_INDEX)).toEqual([ "company_id", + "started_by_user_id", "adapter_type", ]); // The active index is partial; the where clause is present. expect(active?.config.where).toBeDefined(); }); - it("keeps the owner principal non-null and out of the uniqueness key", () => { + it("keeps the owner principal non-null and drops the environment from the key", () => { expect(columnIsNotNull(adapterAuthSessions, "started_by_user_id")).toBe(true); - const activeColumns = indexColumns( - adapterAuthSessions, - "adapter_auth_sessions_company_adapter_active_uq", - ); - expect(activeColumns).not.toContain("started_by_user_id"); + const activeColumns = indexColumns(adapterAuthSessions, ACTIVE_INDEX); + expect(activeColumns).toContain("started_by_user_id"); expect(activeColumns).not.toContain("environment_id"); }); - it("generates a partial unique index over the three active statuses", () => { + it("adds the public session id, non-null and unique", () => { + expect(column(adapterAuthSessions, "public_session_id")).toBeDefined(); + expect(columnIsNotNull(adapterAuthSessions, "public_session_id")).toBe(true); + const uniqueOnPublicSessionId = findIndex( + adapterAuthSessions, + "adapter_auth_sessions_public_session_id_uq", + ); + expect(uniqueOnPublicSessionId).toBeDefined(); + expect(uniqueOnPublicSessionId?.config.unique).toBe(true); + expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_public_session_id_uq")).toEqual([ + "public_session_id", + ]); + }); + + it("adds the claim marker, nullable", () => { + // A row starts with no claim consumption. The service fills `bound_at` only + // when the create path consumes the stored claim. + expect(column(adapterAuthSessions, "bound_at")).toBeDefined(); + expect(columnIsNotNull(adapterAuthSessions, "bound_at")).toBe(false); + }); + + it("merges the two state unions and drops the persisting state", () => { + expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).toEqual([ + "starting", + "waiting_for_user", + "promoting", + "awaiting_code", + "submitting", + ]); + // The removed state stays out of the active set. + expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("persisting"); + // The one-time claim state and the terminal states do not hold the slot. + expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("stored"); + expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("completed"); + }); + + it("holds no secret-bearing column", () => { + // The durable store keeps only ids, the state, the lease reference, and the + // deadlines. It never holds the prompt, the login URL, the browser code, the + // token, or a process chunk. + const names = getTableConfig(adapterAuthSessions).columns.map((c) => c.name); + const forbidden = ["token", "code", "url", "secret", "credential", "prompt", "output"]; + for (const name of names) { + for (const needle of forbidden) { + expect(name.includes(needle)).toBe(false); + } + } + }); + + it("generates the partial unique index over the merged active states", () => { const sql = activeIndexMigrationSql(); - expect(sql).toContain('CREATE UNIQUE INDEX "adapter_auth_sessions_company_adapter_active_uq"'); - expect(sql).toContain('("company_id","adapter_type")'); - expect(sql).toContain("'starting', 'waiting_for_user', 'promoting'"); - expect(sql).toContain('"started_by_user_id" text NOT NULL'); + expect(sql).toContain(`CREATE UNIQUE INDEX "${ACTIVE_INDEX}"`); + expect(sql).toContain('("company_id","started_by_user_id","adapter_type")'); + expect(sql).toContain( + "'starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting'", + ); + // The new non-null column and the unique index are in the migration. + expect(sql).toContain('"public_session_id" varchar(128) NOT NULL'); + expect(sql).toContain(`CREATE UNIQUE INDEX "adapter_auth_sessions_public_session_id_uq"`); + // The removed state and the non-active states stay out of the predicate. + const predicate = sql.slice(sql.indexOf(ACTIVE_INDEX)); + const whereClause = predicate.slice(predicate.indexOf("WHERE"), predicate.indexOf(";")); + expect(whereClause).not.toContain("'persisting'"); + expect(whereClause).not.toContain("'stored'"); + expect(whereClause).not.toContain("'completed'"); + expect(whereClause).not.toContain("'authenticated'"); }); }); diff --git a/packages/db/src/claude-setup-token-sessions-schema.test.ts b/packages/db/src/claude-setup-token-sessions-schema.test.ts deleted file mode 100644 index b9b72fb6c55..00000000000 --- a/packages/db/src/claude-setup-token-sessions-schema.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import fs from "node:fs"; -import { getTableConfig } from "drizzle-orm/pg-core"; -import { describe, expect, it } from "vitest"; -import { claudeSetupTokenSessions } from "./schema/claude_setup_token_sessions.js"; - -type PgTable = Parameters[0]; - -function findIndex(table: PgTable, indexName: string) { - return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName); -} - -function indexColumns(table: PgTable, indexName: string): string[] { - const index = findIndex(table, indexName); - if (!index) return []; - return index.config.columns.map((column) => (column as { name: string }).name); -} - -function column(table: PgTable, columnName: string) { - return getTableConfig(table).columns.find((candidate) => candidate.name === columnName); -} - -function columnIsNotNull(table: PgTable, columnName: string): boolean { - return column(table, columnName)?.notNull ?? false; -} - -const ACTIVE_INDEX = "claude_setup_token_sessions_active_uq"; - -// The migration that creates the table. The test locates it by content, so a -// later re-generation with a different name does not break the test. -function activeIndexMigrationSql(): string { - const migrationsDir = new URL("./migrations/", import.meta.url); - const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")); - for (const name of files) { - const content = fs.readFileSync(new URL(name, migrationsDir), "utf8"); - if (content.includes(ACTIVE_INDEX)) { - return content; - } - } - throw new Error(`no migration creates ${ACTIVE_INDEX}`); -} - -describe("claude setup token sessions schema", () => { - it("keeps the scope columns and the deadline non-null", () => { - expect(columnIsNotNull(claudeSetupTokenSessions, "session_id")).toBe(true); - expect(columnIsNotNull(claudeSetupTokenSessions, "company_id")).toBe(true); - expect(columnIsNotNull(claudeSetupTokenSessions, "owner_user_id")).toBe(true); - expect(columnIsNotNull(claudeSetupTokenSessions, "adapter_type")).toBe(true); - expect(columnIsNotNull(claudeSetupTokenSessions, "environment_id")).toBe(true); - expect(columnIsNotNull(claudeSetupTokenSessions, "state")).toBe(true); - expect(columnIsNotNull(claudeSetupTokenSessions, "deadline_at")).toBe(true); - }); - - it("keeps the claim marker and the lease reference nullable", () => { - // A row starts with no claim consumption and no lease. The service fills - // `bound_at` only when the create path consumes the stored claim. - expect(column(claudeSetupTokenSessions, "bound_at")).toBeDefined(); - expect(columnIsNotNull(claudeSetupTokenSessions, "bound_at")).toBe(false); - expect(column(claudeSetupTokenSessions, "lease_id")).toBeDefined(); - expect(columnIsNotNull(claudeSetupTokenSessions, "lease_id")).toBe(false); - }); - - it("references the company and the environment", () => { - const foreignKeys = getTableConfig(claudeSetupTokenSessions).foreignKeys; - const referencedTables = foreignKeys.map((fk) => fk.reference().foreignTable); - const referencedNames = referencedTables.map( - (table) => getTableConfig(table as PgTable).name, - ); - expect(referencedNames).toContain("companies"); - expect(referencedNames).toContain("environments"); - }); - - it("holds no secret-bearing column", () => { - // The durable store keeps only ids, the state, and the deadlines. It never - // holds the login URL, the browser code, the token, or a process chunk. - const names = getTableConfig(claudeSetupTokenSessions).columns.map((c) => c.name); - const forbidden = ["token", "code", "url", "secret", "credential", "prompt", "output"]; - for (const name of names) { - for (const needle of forbidden) { - expect(name.includes(needle)).toBe(false); - } - } - }); - - it("serializes the active session over the full owner scope", () => { - const active = findIndex(claudeSetupTokenSessions, ACTIVE_INDEX); - expect(active).toBeDefined(); - expect(active?.config.unique).toBe(true); - expect(indexColumns(claudeSetupTokenSessions, ACTIVE_INDEX)).toEqual([ - "company_id", - "owner_user_id", - "adapter_type", - "environment_id", - ]); - // The active index is partial; the where clause is present. - expect(active?.config.where).toBeDefined(); - }); - - it("scopes the active partial unique index to the active states only", () => { - const sql = activeIndexMigrationSql(); - expect(sql).toContain(`CREATE UNIQUE INDEX "${ACTIVE_INDEX}"`); - expect(sql).toContain('("company_id","owner_user_id","adapter_type","environment_id")'); - // The active states are in the predicate. - expect(sql).toContain("'starting'"); - expect(sql).toContain("'awaiting_code'"); - expect(sql).toContain("'submitting'"); - expect(sql).toContain("'persisting'"); - // The stored state and the terminal states stay out of the predicate. - const predicate = sql.slice(sql.indexOf(ACTIVE_INDEX)); - const whereClause = predicate.slice(predicate.indexOf("WHERE"), predicate.indexOf(";")); - expect(whereClause).not.toContain("'stored'"); - expect(whereClause).not.toContain("'completed'"); - expect(whereClause).not.toContain("'failed'"); - expect(whereClause).not.toContain("'timed_out'"); - expect(whereClause).not.toContain("'cancelled'"); - }); -}); diff --git a/packages/db/src/migrations/0224_unified_adapter_auth_sessions.sql b/packages/db/src/migrations/0224_unified_adapter_auth_sessions.sql new file mode 100644 index 00000000000..c848dd06ce2 --- /dev/null +++ b/packages/db/src/migrations/0224_unified_adapter_auth_sessions.sql @@ -0,0 +1,18 @@ +-- Extend "adapter_auth_sessions" to hold both login flows. The migration adds +-- the public session id and the claim marker, and it re-scopes the active +-- credential slot to company + owner + adapter. The migration copies no rows +-- from "claude_setup_token_sessions"; that table stays in place for a later drop. +-- +-- NOT NULL on a live table: "public_session_id" is a new non-null column with no +-- default, and "adapter_auth_sessions" can hold rows when the migration runs. The +-- rows are short-lived login sessions, and the board excluded deploy-window +-- support, so the migration deletes the existing rows instead of a backfill of a +-- generated value. A dropped login session re-starts on the next request. The +-- table stays small because the reaper removes finished rows, so the one-time +-- full delete is safe. +DELETE FROM "adapter_auth_sessions";--> statement-breakpoint +ALTER TABLE "adapter_auth_sessions" ADD COLUMN "public_session_id" varchar(128) NOT NULL;--> statement-breakpoint +ALTER TABLE "adapter_auth_sessions" ADD COLUMN "bound_at" timestamp with time zone;--> statement-breakpoint +DROP INDEX "adapter_auth_sessions_company_adapter_active_uq";--> statement-breakpoint +CREATE UNIQUE INDEX "adapter_auth_sessions_company_owner_adapter_active_uq" ON "adapter_auth_sessions" USING btree ("company_id","started_by_user_id","adapter_type") WHERE "adapter_auth_sessions"."status" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting');--> statement-breakpoint +CREATE UNIQUE INDEX "adapter_auth_sessions_public_session_id_uq" ON "adapter_auth_sessions" USING btree ("public_session_id"); diff --git a/packages/db/src/migrations/0225_drop_claude_setup_token_sessions.sql b/packages/db/src/migrations/0225_drop_claude_setup_token_sessions.sql new file mode 100644 index 00000000000..2e0cbc0664a --- /dev/null +++ b/packages/db/src/migrations/0225_drop_claude_setup_token_sessions.sql @@ -0,0 +1,5 @@ +-- Drop the old Claude setup-token table. The unified "adapter_auth_sessions" +-- table now holds the Claude setup-token store for both login flows, so the +-- old table has no reader. Migration 0224 moved the columns and the active-slot +-- index onto "adapter_auth_sessions"; this migration removes the dead table. +DROP TABLE IF EXISTS "claude_setup_token_sessions"; diff --git a/packages/db/src/migrations/meta/0225_snapshot.json b/packages/db/src/migrations/meta/0225_snapshot.json new file mode 100644 index 00000000000..3285ef982ad --- /dev/null +++ b/packages/db/src/migrations/meta/0225_snapshot.json @@ -0,0 +1,39600 @@ +{ + "id": "5f26d1e4-4e78-4976-8a1f-1bd860931b8d", + "prevId": "d925c84b-41b6-42d1-a0f9-4ee090ef3cd1", + "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.adapter_auth_sessions": { + "name": "adapter_auth_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 + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_session_id": { + "name": "public_session_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "provider_lease_id": { + "name": "provider_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'starting'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "promotion_expires_at": { + "name": "promotion_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bound_at": { + "name": "bound_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 + }, + "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": { + "adapter_auth_sessions_company_status_idx": { + "name": "adapter_auth_sessions_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": {} + }, + "adapter_auth_sessions_company_owner_adapter_active_uq": { + "name": "adapter_auth_sessions_company_owner_adapter_active_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "adapter_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_public_session_id_uq": { + "name": "adapter_auth_sessions_public_session_id_uq", + "columns": [ + { + "expression": "public_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_environment_idx": { + "name": "adapter_auth_sessions_environment_idx", + "columns": [ + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_expires_idx": { + "name": "adapter_auth_sessions_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "adapter_auth_sessions_provider_lease_idx": { + "name": "adapter_auth_sessions_provider_lease_idx", + "columns": [ + { + "expression": "provider_lease_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "adapter_auth_sessions_company_id_companies_id_fk": { + "name": "adapter_auth_sessions_company_id_companies_id_fk", + "tableFrom": "adapter_auth_sessions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "adapter_auth_sessions_environment_id_environments_id_fk": { + "name": "adapter_auth_sessions_environment_id_environments_id_fk", + "tableFrom": "adapter_auth_sessions", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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_onboarding_seeds": { + "name": "company_onboarding_seeds", + "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 + }, + "revision": { + "name": "revision", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mission": { + "name": "mission", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_role": { + "name": "agent_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_task_title": { + "name": "first_task_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_task_details": { + "name": "first_task_details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "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 + }, + "applied_at": { + "name": "applied_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": { + "company_onboarding_seeds_company_uq": { + "name": "company_onboarding_seeds_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_onboarding_seeds_company_id_companies_id_fk": { + "name": "company_onboarding_seeds_company_id_companies_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_onboarding_seeds_goal_id_goals_id_fk": { + "name": "company_onboarding_seeds_goal_id_goals_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_onboarding_seeds_agent_id_agents_id_fk": { + "name": "company_onboarding_seeds_agent_id_agents_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "company_onboarding_seeds_issue_id_issues_id_fk": { + "name": "company_onboarding_seeds_issue_id_issues_id_fk", + "tableFrom": "company_onboarding_seeds", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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 + }, + "interaction_id": { + "name": "interaction_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 + }, + "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": {} + }, + "company_secret_proposals_interaction_idx": { + "name": "company_secret_proposals_interaction_idx", + "columns": [ + { + "expression": "interaction_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_interaction_id_issue_thread_interactions_id_fk": { + "name": "company_secret_proposals_interaction_id_issue_thread_interactions_id_fk", + "tableFrom": "company_secret_proposals", + "tableTo": "issue_thread_interactions", + "columnsFrom": [ + "interaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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_transfer_runs": { + "name": "company_transfer_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": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "actor_key": { + "name": "actor_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "container_ref": { + "name": "container_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manifest_sha256": { + "name": "manifest_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest": { + "name": "manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "blob_count": { + "name": "blob_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_parts": { + "name": "completed_parts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "error": { + "name": "error", + "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 + }, + "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_transfer_runs_company_idx": { + "name": "company_transfer_runs_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_transfer_runs_idempotency_direction_idx": { + "name": "company_transfer_runs_idempotency_direction_idx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_transfer_runs_actor_status_idx": { + "name": "company_transfer_runs_actor_status_idx", + "columns": [ + { + "expression": "actor_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_transfer_runs_company_id_companies_id_fk": { + "name": "company_transfer_runs_company_id_companies_id_fk", + "tableFrom": "company_transfer_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_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": "set null", + "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": 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 + }, + "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": "set null", + "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_workspace_runtime_leases": { + "name": "execution_workspace_runtime_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 + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_key": { + "name": "owner_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_issue_id": { + "name": "owner_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_run_id": { + "name": "owner_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_action": { + "name": "last_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "renewed_at": { + "name": "renewed_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": true + }, + "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_workspace_runtime_leases_company_workspace_idx": { + "name": "execution_workspace_runtime_leases_company_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": {} + }, + "execution_workspace_runtime_leases_company_owner_idx": { + "name": "execution_workspace_runtime_leases_company_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_runtime_leases_expires_at_idx": { + "name": "execution_workspace_runtime_leases_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspace_runtime_leases_company_id_companies_id_fk": { + "name": "execution_workspace_runtime_leases_company_id_companies_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspace_runtime_leases_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk": { + "name": "execution_workspace_runtime_leases_owner_issue_id_issues_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "issues", + "columnsFrom": [ + "owner_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk": { + "name": "execution_workspace_runtime_leases_owner_run_id_heartbeat_runs_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "owner_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk": { + "name": "execution_workspace_runtime_leases_owner_agent_id_agents_id_fk", + "tableFrom": "execution_workspace_runtime_leases", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "execution_workspace_runtime_leases_execution_workspace_id_unique": { + "name": "execution_workspace_runtime_leases_execution_workspace_id_unique", + "nullsNotDistinct": false, + "columns": [ + "execution_workspace_id" + ] + } + }, + "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": "cascade", + "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": "set null", + "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": "cascade", + "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": "cascade", + "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": "cascade", + "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": "'anyone'" + }, + "effective_resolver_policy": { + "name": "effective_resolver_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anyone'" + }, + "resolver_policy_provenance": { + "name": "resolver_policy_provenance", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherited'" + }, + "effective_resolver_policy_source": { + "name": "effective_resolver_policy_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "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": "cascade", + "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": {} + }, + "issues_onboarding_first_task_uq": { + "name": "issues_onboarding_first_task_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'onboarding_first_task'", + "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 + }, + "exposure": { + "name": "exposure", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "exposure_handle": { + "name": "exposure_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backend_url": { + "name": "backend_url", + "type": "text", + "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 b324b1ed508..f06b8e1d559 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1555,6 +1555,20 @@ "when": 1787000753489, "tag": "0223_robust_zaladane", "breakpoints": true + }, + { + "idx": 224, + "version": "7", + "when": 1787000754489, + "tag": "0224_unified_adapter_auth_sessions", + "breakpoints": true + }, + { + "idx": 225, + "version": "7", + "when": 1787101144413, + "tag": "0225_drop_claude_setup_token_sessions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/adapter_auth_sessions.ts b/packages/db/src/schema/adapter_auth_sessions.ts index cbdfc4c9f23..02f4851c712 100644 --- a/packages/db/src/schema/adapter_auth_sessions.ts +++ b/packages/db/src/schema/adapter_auth_sessions.ts @@ -1,13 +1,47 @@ import { sql } from "drizzle-orm"; -import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core"; import type { AdapterAuthSessionInternalStatus, AgentAdapterType } from "@paperclipai/shared"; import { companies } from "./companies.js"; import { environments } from "./environments.js"; +// The merged state set for the unified login table. One column holds the state +// of both login flows: the device-login flow and the setup-token flow. The set +// joins the two prior state unions into one union. The set drops the dead +// `persisting` state. +// +// The active states hold the company credential slot for one company, owner, and +// adapter. The partial unique index applies to the active states only. +// - `starting`, `waiting_for_user`, `promoting`: the device-login active states. +// - `awaiting_code`, `submitting`: the setup-token active states. +// The non-active states do not hold the slot: +// - `stored`: the one-time setup-token claim. The create path consumes it and +// writes `bound_at`. +// - `authenticated`, `completed`: the terminal success states. +// - `cleanup_pending`: the terminal state whose sandbox delete failed. +// - `failed`, `timed_out`, `cancelled`: the shared terminal failure states. +export type AdapterAuthSessionState = + | AdapterAuthSessionInternalStatus + | "awaiting_code" + | "submitting" + | "stored" + | "completed"; + +// The active states of the merged set. The partial unique index and the store +// use this list. The list excludes `stored`, every terminal state, and the +// removed `persisting` state. +export const ADAPTER_AUTH_SESSION_ACTIVE_STATES = [ + "starting", + "waiting_for_user", + "promoting", + "awaiting_code", + "submitting", +] as const satisfies readonly AdapterAuthSessionState[]; + // The durable store for an adapter login session. One row tracks one login // attempt for one adapter in one environment. The row keeps the owner principal, -// the provider lease reference, the status, and the finish times. The row never -// stores the prompt, a credential byte, or the raw provider secret. +// the public session id, the provider lease reference, the status, and the +// finish times. The row never stores the prompt, a credential byte, or the raw +// provider secret. export const adapterAuthSessions = pgTable( "adapter_auth_sessions", { @@ -17,12 +51,20 @@ export const adapterAuthSessions = pgTable( adapterType: text("adapter_type").$type().notNull(), // The immutable owner principal. The service sets this column one time at // create and never updates it. The service returns the prompt only to this - // owner. + // owner. The active-slot index includes this column, so the column stays + // non-null; a null owner escapes a partial unique index. startedByUserId: text("started_by_user_id").notNull(), + // The opaque public session id. The public API returns this id, and the + // store keys its lookups by it. The column is non-null, length-bounded, and + // unique. The store fills it at create with a CSPRNG value. + publicSessionId: varchar("public_session_id", { length: 128 }).notNull(), // The provider lease reference for the sandbox. The reaper reads it to retry // a failed sandbox delete. It is not a public field. providerLeaseId: text("provider_lease_id"), - status: text("status").$type().notNull().default("starting"), + // The merged login state. The column is `text`, so it stores every value of + // the merged `AdapterAuthSessionState` set. The compile-time `$type` is the + // merged union, so both login stores share one status type. + status: text("status").$type().notNull().default("starting"), expiresAt: timestamp("expires_at", { withTimezone: true }), // The promotion claim deadline. The service sets this column when it moves the // row to `promoting`. While the deadline is in the future, the claim is live, @@ -31,6 +73,11 @@ export const adapterAuthSessions = pgTable( // stalled `promoting` row. The service clears the column on every terminal // transition. promotionExpiresAt: timestamp("promotion_expires_at", { withTimezone: true }), + // The claim-consumption marker for a `stored` row. The column is null while + // the stored claim is live. The create path sets it one time when it consumes + // the claim. A non-null value marks a consumed claim, so the reaper removes + // the row. A device-login row never sets this column. + boundAt: timestamp("bound_at", { withTimezone: true }), finishedAt: timestamp("finished_at", { withTimezone: true }), // The fixed, non-secret failure code. The public response reads it. failureReason: text("failure_reason"), @@ -43,11 +90,19 @@ export const adapterAuthSessions = pgTable( table.status, ), // Serialize on the company credential slot. Only one active session can hold - // the slot per adapter. The index applies to the three active statuses. It - // does not include the owner or the environment; those stay columns only. - companyAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_adapter_active_uq") - .on(table.companyId, table.adapterType) - .where(sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting')`), + // the slot per company, owner, and adapter. The index applies to the active + // states of the merged set. It does not include the environment; the board + // rule scopes the slot to the owner, not the environment. + companyOwnerAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_owner_adapter_active_uq") + .on(table.companyId, table.startedByUserId, table.adapterType) + .where( + sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')`, + ), + // The public session id is unique across the table. The store keys its + // lookups by this id. + publicSessionIdUq: uniqueIndex("adapter_auth_sessions_public_session_id_uq").on( + table.publicSessionId, + ), environmentIdx: index("adapter_auth_sessions_environment_idx").on(table.environmentId), expiresIdx: index("adapter_auth_sessions_expires_idx").on(table.expiresAt), providerLeaseIdx: index("adapter_auth_sessions_provider_lease_idx").on(table.providerLeaseId), diff --git a/packages/db/src/schema/claude_setup_token_sessions.ts b/packages/db/src/schema/claude_setup_token_sessions.ts deleted file mode 100644 index cc32e08f3d3..00000000000 --- a/packages/db/src/schema/claude_setup_token_sessions.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { sql } from "drizzle-orm"; -import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; -import type { AgentAdapterType } from "@paperclipai/shared"; -import { companies } from "./companies.js"; -import { environments } from "./environments.js"; - -/** - * The durable state of one Claude setup-token cleanup record. The store never - * reuses `adapter_auth_sessions`; this table is a dedicated, non-secret cleanup - * store. The active states hold a live login. The `stored` state marks a session - * that wrote the owner-bound secret and now persists as a one-time claim. The - * create path consumes the claim by setting `bound_at`. The reaper removes the - * row after `deadline_at`. The terminal states end the login. - */ -export type ClaudeSetupTokenSessionState = - | "starting" - | "awaiting_code" - | "submitting" - | "persisting" - | "stored" - | "completed" - | "failed" - | "timed_out" - | "cancelled"; - -// The active states hold the company credential slot. The partial unique index -// applies to these states only. It does not include `stored` or a terminal -// state, so a stored claim and a terminal record do not block a new login. -export const CLAUDE_SETUP_TOKEN_ACTIVE_STATES = [ - "starting", - "awaiting_code", - "submitting", - "persisting", -] as const satisfies readonly ClaudeSetupTokenSessionState[]; - -// The durable store for one Claude setup-token cleanup record. One row tracks -// one login for one owner, one adapter, and one environment. The row keeps only -// ids, the state, the sandbox lease reference, and the deadlines. The row never -// holds the login URL, the browser code, the token, or a process chunk. -export const claudeSetupTokenSessions = pgTable( - "claude_setup_token_sessions", - { - id: uuid("id").primaryKey().defaultRandom(), - // The opaque service session id. The store keys every update by this id. - sessionId: text("session_id").notNull(), - companyId: uuid("company_id") - .notNull() - .references(() => companies.id, { onDelete: "cascade" }), - // The immutable owner principal. The service sets this column one time at - // create and never updates it. - ownerUserId: text("owner_user_id").notNull(), - adapterType: text("adapter_type").$type().notNull(), - environmentId: uuid("environment_id") - .notNull() - .references(() => environments.id, { onDelete: "cascade" }), - // The provider lease reference for the sandbox. The reaper reads it to - // release a lease that a crash or a failure left behind. It is not a secret. - leaseId: text("lease_id"), - state: text("state").$type().notNull().default("starting"), - // The login deadline. The reaper removes the row after this time. The - // claim-consumption write also checks this column with `clock_timestamp()`. - deadlineAt: timestamp("deadline_at", { withTimezone: true }).notNull(), - // The claim-consumption marker. It is null while the stored claim is live. - // The create path sets it one time when it consumes the claim. A non-null - // value marks a consumed claim, so the reaper removes the row. - boundAt: timestamp("bound_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => ({ - // Serialize the active login on the full owner scope. Only one active - // session can run per company, owner, adapter, and environment. The index - // applies to the active states only; it excludes `stored` and every terminal - // state, so a stored claim and a terminal record never block a new login. - activeUq: uniqueIndex("claude_setup_token_sessions_active_uq") - .on(table.companyId, table.ownerUserId, table.adapterType, table.environmentId) - .where(sql`${table.state} IN ('starting', 'awaiting_code', 'submitting', 'persisting')`), - // The store keys every update by the opaque session id, so it stays unique. - sessionIdUq: uniqueIndex("claude_setup_token_sessions_session_id_uq").on(table.sessionId), - // The reaper scans by the deadline to remove an expired record. - deadlineIdx: index("claude_setup_token_sessions_deadline_idx").on(table.deadlineAt), - }), -); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 16fd35c306b..ad878e2e830 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -35,11 +35,6 @@ export { environmentLeases } from "./environment_leases.js"; export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js"; export { environmentCustomImageSetupSessions } from "./environment_custom_image_setup_sessions.js"; export { adapterAuthSessions } from "./adapter_auth_sessions.js"; -export { - claudeSetupTokenSessions, - CLAUDE_SETUP_TOKEN_ACTIVE_STATES, - type ClaudeSetupTokenSessionState, -} from "./claude_setup_token_sessions.js"; export { workspaceOperations } from "./workspace_operations.js"; export { workspaceRuntimeServices } from "./workspace_runtime_services.js"; export { projectGoals } from "./project_goals.js"; diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index a7e19fdc900..26f65f22140 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -1,10 +1,12 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; const PLUGIN_ID = "paperclip.daytona-sandbox-provider"; -// 0.1.2 adds `supportsSetupTokenLogin` to the driver. The version bump makes -// the bundled-plugin boot reconcile refresh the persisted manifest for an -// existing install, so the Claude setup-token login capability propagates. -const PLUGIN_VERSION = "0.1.2"; +// 0.1.3 renames the login transport flag from `supportsSetupTokenLogin` to the +// neutral `supportsLoginPty`. The boot reconcile reads the persisted manifest +// raw and does not re-run the validator, so it never canonicalizes the old +// name. The version bump makes the reconcile refresh the persisted manifest for +// an existing install, so the renamed capability propagates. +const PLUGIN_VERSION = "0.1.3"; const manifest: PaperclipPluginManifestV1 = { id: PLUGIN_ID, @@ -44,10 +46,10 @@ const manifest: PaperclipPluginManifestV1 = { }, templateIdentityPaths: ["apiUrl"], supportsTemplateDelete: true, - // Daytona hosts the Claude setup-token login on a real pseudo-terminal. - // It is the only bundled provider that implements the setup-token - // pseudo-terminal methods, so it advertises the capability. - supportsSetupTokenLogin: true, + // Daytona hosts an interactive login on a real pseudo-terminal. It is the + // only bundled provider that implements the login pseudo-terminal methods, + // so it advertises the capability. + supportsLoginPty: true, configSchema: { type: "object", properties: { diff --git a/packages/shared/src/adapter-auth-check-code.test.ts b/packages/shared/src/adapter-auth-check-code.test.ts new file mode 100644 index 00000000000..5f466b28870 --- /dev/null +++ b/packages/shared/src/adapter-auth-check-code.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from "vitest"; + +import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js"; + +describe("ADAPTER_AUTH_MISSING_CHECK_CODE", () => { + it("keeps the canonical missing-auth check code stable", () => { + expect(ADAPTER_AUTH_MISSING_CHECK_CODE).toBe("adapter_auth_missing"); + }); +}); diff --git a/packages/shared/src/adapter-auth-check-code.ts b/packages/shared/src/adapter-auth-check-code.ts new file mode 100644 index 00000000000..a8995b2d487 --- /dev/null +++ b/packages/shared/src/adapter-auth-check-code.ts @@ -0,0 +1,12 @@ +/** + * The canonical check code an adapter Test engine emits when a sandbox target + * has no ready authentication. The user interface reads this stable code to + * decide login eligibility. The user interface does not read the message text + * or the top-level status. + * + * The name is neutral. It carries no vendor name and no login-flow word, because + * a Test result can sync to public packages. The adapters and the user interface + * import this one constant, so the user interface can gate login from one code + * across adapters. + */ +export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing"; diff --git a/packages/shared/src/environment-support.ts b/packages/shared/src/environment-support.ts index bc8b56df768..e08e32d76c1 100644 --- a/packages/shared/src/environment-support.ts +++ b/packages/shared/src/environment-support.ts @@ -49,7 +49,7 @@ export interface EnvironmentProviderCapability { templateRefKind?: string; templateConfigBinding?: PluginEnvironmentTemplateConfigBinding; supportsTemplateDelete: boolean; - supportsSetupTokenLogin: boolean; + supportsLoginPty: boolean; displayName?: string; description?: string; source?: "builtin" | "plugin"; @@ -157,7 +157,7 @@ export function getEnvironmentCapabilities( interactiveSetupConnectionTypes: [], supportsTemplateCapture: false, supportsTemplateDelete: false, - supportsSetupTokenLogin: false, + supportsLoginPty: false, displayName: "Fake", source: "builtin", }, @@ -177,7 +177,7 @@ export function getEnvironmentCapabilities( templateRefKind: capability.templateRefKind, templateConfigBinding: capability.templateConfigBinding, supportsTemplateDelete: capability.supportsTemplateDelete ?? false, - supportsSetupTokenLogin: capability.supportsSetupTokenLogin ?? false, + supportsLoginPty: capability.supportsLoginPty ?? false, displayName: capability.displayName, description: capability.description, source: capability.source ?? "plugin", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f9d9ef0f439..03c413d7f50 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; +export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js"; export { decisionEffectStalenessSchema, decisionOptionStyleSchema, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 3268a3a847e..7bbd845a45d 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -216,10 +216,19 @@ export interface PluginEnvironmentDriverDeclaration { /** Provider supports best-effort deletion/cleanup of captured templates. */ supportsTemplateDelete?: boolean; /** - * Provider can host the Claude setup-token login on a real pseudo-terminal. - * Only a provider with this flag exposes the setup-token pseudo-terminal - * methods. The setup-token login server and the login UI both gate on this - * flag, so a provider without it never starts a login. + * Provider can host an interactive login on a real pseudo-terminal. Only a + * provider with this flag exposes the login pseudo-terminal methods. The login + * server and the login UI both gate on this flag, so a provider without it + * never starts a login. + */ + supportsLoginPty?: boolean; + /** + * Deprecated alias for `supportsLoginPty`. It exists only so an external + * plugin manifest that declares the old name still loads. The manifest + * validator canonicalizes it onto `supportsLoginPty` and drops it. Do not read + * this field; read `supportsLoginPty`. + * + * @deprecated Use `supportsLoginPty`. */ supportsSetupTokenLogin?: boolean; /** JSON Schema describing the driver's provider-specific configuration. */ diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 4cd52fe0590..093d15dde6d 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -350,3 +350,64 @@ describe("sandbox provider capability declaration validators", () => { expect(resolveDeclaredSandboxCapabilities(driver!).reusableLeases).toBe(false); }); }); + +describe("login pty transport capability and legacy alias", () => { + it("test_login_pty_new_field_parses_and_carries_the_flag", () => { + const parsed = pluginManifestV1Schema.parse( + buildSandboxProviderManifest({ supportsLoginPty: true }), + ); + + expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true); + }); + + it("test_legacy_alias_only_canonicalizes_onto_login_pty", () => { + const parsed = pluginManifestV1Schema.parse( + buildSandboxProviderManifest({ supportsSetupTokenLogin: true }), + ); + const driver = parsed.environmentDrivers?.[0]; + + // The validator maps the deprecated alias onto the canonical field at parse + // time, and it drops the alias so a downstream reader cannot read the old + // name. + expect(driver?.supportsLoginPty).toBe(true); + expect(driver).not.toHaveProperty("supportsSetupTokenLogin"); + }); + + it("test_conflicting_alias_and_new_field_reject", () => { + const rejected = pluginManifestV1Schema.safeParse( + buildSandboxProviderManifest({ + supportsLoginPty: true, + supportsSetupTokenLogin: false, + }), + ); + + expect(rejected.success).toBe(false); + }); + + it("test_matching_alias_and_new_field_pass", () => { + const parsed = pluginManifestV1Schema.parse( + buildSandboxProviderManifest({ + supportsLoginPty: true, + supportsSetupTokenLogin: true, + }), + ); + + expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true); + }); + + it("test_unknown_login_field_misspelling_rejects", () => { + const rejected = pluginManifestV1Schema.safeParse( + buildSandboxProviderManifest({ supportsLoginPTY: true }), + ); + + expect(rejected.success).toBe(false); + }); + + it("test_login_pty_accepts_literal_booleans_only", () => { + const rejected = pluginManifestV1Schema.safeParse( + buildSandboxProviderManifest({ supportsLoginPty: "true" }), + ); + + expect(rejected.success).toBe(false); + }); +}); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 9145f19b791..7151aa99530 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -157,8 +157,8 @@ export const pluginEnvironmentTemplateConfigBindingSchema = z.object({ // The nested sandbox capability declaration is `.strict()` so an unknown // capability key is a validation error, not a silently dropped field. The outer -// driver schema below is non-strict and drops unknown top-level keys, so the -// declaration itself is the gate that a typo in a capability name cannot pass. +// driver schema below is also `.strict()`, so a typo in a top-level capability +// name cannot pass either. export const sandboxProviderCapabilitiesSchema = z.object({ reusableLeases: z.boolean().optional(), nativeSyncIn: z.boolean().optional(), @@ -170,6 +170,9 @@ export const sandboxProviderCapabilitiesSchema = z.object({ export type SandboxProviderCapabilitiesInput = z.infer; +// The driver declaration schema is `.strict()` so an unknown top-level key is a +// validation error, not a silently dropped field. A misspelled capability name +// (for example `supportsLoginPTY`) fails validation instead of dropping. export const pluginEnvironmentDriverDeclarationSchema = z.object({ driverKey: z.string().min(1).regex( /^[a-z0-9][a-z0-9._-]*$/, @@ -187,8 +190,38 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({ templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(), templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(), supportsTemplateDelete: z.boolean().optional(), + // The neutral transport capability: does the provider host an interactive + // login on a real pseudo-terminal? Validate a literal boolean only. + supportsLoginPty: z.boolean().optional(), + // Deprecated alias for `supportsLoginPty`. It exists only so an external + // plugin manifest that declares the old name still loads. Validate a literal + // boolean only. The transform below canonicalizes it onto `supportsLoginPty` + // and drops it, so every downstream reader reads only the canonical field. supportsSetupTokenLogin: z.boolean().optional(), configSchema: jsonSchemaSchema, +}).strict().superRefine((driver, ctx) => { + // Reject a manifest that carries both the alias and the canonical field with + // different values. Matching values pass. A single field passes. + if ( + driver.supportsLoginPty !== undefined + && driver.supportsSetupTokenLogin !== undefined + && driver.supportsLoginPty !== driver.supportsSetupTokenLogin + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "supportsSetupTokenLogin is a deprecated alias for supportsLoginPty; the two flags must not carry different values", + path: ["supportsLoginPty"], + }); + } +}).transform((driver) => { + // Canonicalize before any authorization or projection reads the value. The + // canonical field wins; the alias fills it in when only the alias is present. + // Drop the alias so every downstream reader reads only `supportsLoginPty`. An + // undefined value is omitted on JSON serialization, so a driver that declares + // neither field carries no login flag. + const { supportsSetupTokenLogin, ...rest } = driver; + return { ...rest, supportsLoginPty: rest.supportsLoginPty ?? supportsSetupTokenLogin }; }); export type PluginEnvironmentDriverDeclarationInput = z.infer< diff --git a/server/src/__tests__/agent-device-login-routes.test.ts b/server/src/__tests__/agent-device-login-routes.test.ts index d0b2c3869a5..84780e01c85 100644 --- a/server/src/__tests__/agent-device-login-routes.test.ts +++ b/server/src/__tests__/agent-device-login-routes.test.ts @@ -177,19 +177,22 @@ vi.mock("../services/codex-device-login-service.js", async (importOriginal) => { function createMemoryStore(): AdapterAuthSessionStore & { rows: Map } { const rows = new Map(); const activeSlots = new Set(); - const slotKey = (companyId: string, adapterType: string) => `${companyId}|${adapterType}`; + // The active slot is scoped to the company, the owner, and the adapter. + const slotKey = (companyId: string, startedByUserId: string, adapterType: string) => + `${companyId}|${startedByUserId}|${adapterType}`; const isActive = (status: AdapterAuthSessionRow["status"]) => status === "starting" || status === "waiting_for_user" || status === "promoting"; return { rows, async insert(input) { - const key = slotKey(input.companyId, input.adapterType); + const key = slotKey(input.companyId, input.startedByUserId, input.adapterType); if (activeSlots.has(key)) { throw new AdapterAuthSessionConflictError(); } activeSlots.add(key); rows.set(input.id, { id: input.id, + publicSessionId: input.publicSessionId, companyId: input.companyId, environmentId: input.environmentId, adapterType: input.adapterType, @@ -213,7 +216,8 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map { adapterType: "codex_local", startedByUserId: OWNER_A, }); - // The row persists the immutable owner from the actor. + // The row persists the immutable owner from the actor. The response carries + // the public session id, so read the row by the public id, not the internal id. const store = harness.store as ReturnType; - const row = store.rows.get(res.body.sessionId); + const row = await store.getByPublicId(res.body.sessionId, COMPANY_1); expect(row?.startedByUserId).toBe(OWNER_A); + // The public session id is never the internal row id. + expect(row?.id).not.toBe(res.body.sessionId); + // No row is keyed by the public session id in the internal-id map. + expect(store.rows.get(res.body.sessionId)).toBeUndefined(); }); - it("rejects a non-codex adapter", async () => { + it("rejects an adapter whose login capability drives a different transport", async () => { + // The Claude adapter declares a pseudo-terminal login, not a streamed-exec + // device login. The guard reads the capability transport, so it rejects the + // adapter with a fixed 400 before any lease. const app = await createApp(); const res = await request(app) @@ -387,6 +410,58 @@ describe("adapter device-login routes", () => { expect(harness.acquisitions).toHaveLength(0); }); + it("starts a device login for a third adapter that declares the streamed-exec capability", async () => { + // A third adapter, not the Codex adapter, declares a streamed-exec login + // capability. The guard reads the registry capability, not the adapter name, + // so the adapter passes the guard and starts a session. This proves no + // adapter-name branch remains in the guard path. The test overrides an + // existing adapter type so the strict request schema accepts it. + const app = await createApp(); + const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js"); + registerServerAdapter({ + type: "gemini_local", + execute: async () => { + throw new Error("not used"); + }, + testEnvironment: async () => { + throw new Error("not used"); + }, + loginCapability: { + panelMode: "displayed_code", + sandboxTransport: "streamed_exec", + timeoutPolicy: "caller_bounded", + getCommand: () => "vendor login", + parsePrompt: () => null, + }, + }); + try { + const res = await request(app) + .post(loginPath(COMPANY_1, "gemini_local")) + .send({ environmentId: SANDBOX_ENV_1 }); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" }); + expect(harness.acquisitions).toHaveLength(1); + expect(harness.acquisitions[0]).toMatchObject({ adapterType: "gemini_local" }); + } finally { + unregisterServerAdapter("gemini_local"); + } + }); + + it("rejects a malformed start body with the strict schema before any side effect", async () => { + const app = await createApp(); + + // The strict start schema rejects an unknown field. The old lax parse + // accepted an extra field and started a session; the shared spine now fails + // the request with a fixed 400 before it acquires a lease. + const res = await request(app) + .post(loginPath(COMPANY_1)) + .send({ environmentId: SANDBOX_ENV_1, unexpectedField: "x" }); + + expect(res.status, JSON.stringify(res.body)).toBe(400); + expect(harness.acquisitions).toHaveLength(0); + }); + it("rejects a local environment", async () => { mockEnvironmentService.getById.mockResolvedValueOnce({ id: SANDBOX_ENV_1, @@ -522,7 +597,7 @@ describe("adapter device-login routes", () => { expect(restart.body.sessionId).not.toBe(sessionId); }); - it("returns 409 for a second active start by a different owner", async () => { + it("lets a second owner start an active login in the same company", async () => { const app = await createApp(); const first = await request(app) @@ -531,13 +606,16 @@ describe("adapter device-login routes", () => { expect(first.status, JSON.stringify(first.body)).toBe(201); // A different owner starts a second login for the same company and adapter. + // The active slot is scoped to the company, the owner, and the adapter, so + // the second owner holds an independent slot and the start succeeds. currentActor = boardActor(OWNER_B); const second = await request(app) .post(loginPath(COMPANY_1)) .send({ environmentId: SANDBOX_ENV_1 }); - expect(second.status, JSON.stringify(second.body)).toBe(409); - // The second start never acquires a lease. - expect(harness.acquisitions).toHaveLength(1); + expect(second.status, JSON.stringify(second.body)).toBe(201); + expect(second.body.sessionId).not.toBe(first.body.sessionId); + // Each owner's start acquires its own lease. + expect(harness.acquisitions).toHaveLength(2); }); it("returns 409 for a second active start in a different environment", async () => { @@ -576,7 +654,10 @@ describe("adapter device-login routes", () => { expect(status.body.failure?.reason).toBe("promotion_failed"); }); - const row = (harness.store as ReturnType).rows.get(sessionId); + const row = await (harness.store as ReturnType).getByPublicId( + sessionId, + COMPANY_1, + ); expect(row?.status).toBe("failed"); expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1); }); @@ -602,7 +683,10 @@ describe("adapter device-login routes", () => { expect(status.body.failure?.reason).toBe("promotion_failed"); }); - const row = (harness.store as ReturnType).rows.get(sessionId); + const row = await (harness.store as ReturnType).getByPublicId( + sessionId, + COMPANY_1, + ); expect(row?.status).toBe("failed"); expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1); }); diff --git a/server/src/__tests__/agents-claude-oauth-binding.test.ts b/server/src/__tests__/agents-claude-oauth-binding.test.ts index 9345b7fe0fe..90ae922e3c4 100644 --- a/server/src/__tests__/agents-claude-oauth-binding.test.ts +++ b/server/src/__tests__/agents-claude-oauth-binding.test.ts @@ -6,8 +6,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { eq, sql } from "drizzle-orm"; import { activityLog, + adapterAuthSessions, agents, - claudeSetupTokenSessions, companies, companySecretBindings, companySecretProviderConfigs, @@ -227,7 +227,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { await db.delete(companySecretProviderConfigs); await db.delete(userSecretDeclarations); await db.delete(userSecretDefinitions); - await db.delete(claudeSetupTokenSessions); + await db.delete(adapterAuthSessions); await db.delete(agents); await db.delete(environments); await db.delete(companies); @@ -303,8 +303,8 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { async function readClaim(sessionId: string) { const rows = await db .select() - .from(claudeSetupTokenSessions) - .where(eq(claudeSetupTokenSessions.sessionId, sessionId)); + .from(adapterAuthSessions) + .where(eq(adapterAuthSessions.publicSessionId, sessionId)); return rows[0]; } @@ -507,10 +507,10 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { }); const lockHeld = lockDb.transaction(async (tx) => { await tx.execute( - sql`SELECT bound_at FROM claude_setup_token_sessions WHERE session_id = ${sessionId} FOR UPDATE`, + sql`SELECT bound_at FROM adapter_auth_sessions WHERE public_session_id = ${sessionId} FOR UPDATE`, ); await tx.execute( - sql`UPDATE claude_setup_token_sessions SET deadline_at = clock_timestamp() + interval '300 milliseconds' WHERE session_id = ${sessionId}`, + sql`UPDATE adapter_auth_sessions SET expires_at = clock_timestamp() + interval '300 milliseconds' WHERE public_session_id = ${sessionId}`, ); signalLocked(); await gate; @@ -639,7 +639,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { expect(JSON.stringify(created)).not.toContain("sk-owner-token"); expect(await countAgents(scope.companyId)).toBe(1); // The path consumes no setup-token claim, so it needs no login round trip. - const claims = await db.select().from(claudeSetupTokenSessions); + const claims = await db.select().from(adapterAuthSessions); expect(claims).toHaveLength(0); }); @@ -803,7 +803,6 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { return { companyId: scope.companyId, ownerUserId: scope.ownerUserId, - targetAgentId: null, adapterType: "claude_local", environmentId: scope.environmentId, }; @@ -818,7 +817,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { // The durable row transitioned to `stored` and stays unconsumed. const claim = await readClaim(sessionId); - expect(claim?.state).toBe("stored"); + expect(claim?.status).toBe("stored"); expect(claim?.boundAt).toBeNull(); // The owner value committed in the same transaction. const status = await secretService(db).readClaudeOAuthUserSecretStatus( @@ -848,7 +847,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { ).rejects.toThrow(); const claim = await readClaim(sessionId); - expect(claim?.state).toBe("submitting"); + expect(claim?.status).toBe("submitting"); expect(claim?.boundAt).toBeNull(); expect( await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId), @@ -865,7 +864,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-EXPIRED" }), ).rejects.toThrow(); - expect((await readClaim(sessionId))?.state).toBe("submitting"); + expect((await readClaim(sessionId))?.status).toBe("submitting"); expect( await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId), ).toBeNull(); @@ -918,7 +917,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => { }); // The claim transitioned and the stored value rotated to a new version. - expect((await readClaim(sessionId))?.state).toBe("stored"); + expect((await readClaim(sessionId))?.status).toBe("stored"); const after = await secretService(db).readClaudeOAuthUserSecretStatus( scope.companyId, scope.ownerUserId, diff --git a/server/src/__tests__/codex-device-login-reaper.test.ts b/server/src/__tests__/codex-device-login-reaper.test.ts index 489c9518e6d..3c7c81b53ad 100644 --- a/server/src/__tests__/codex-device-login-reaper.test.ts +++ b/server/src/__tests__/codex-device-login-reaper.test.ts @@ -86,7 +86,7 @@ function createMemoryReaperStore(): AdapterAuthReaperStore & { const row = rows.get(sessionId); return row ? { ...row } : null; }, - async withCompanyAdapterPromotionLock(_companyId, _adapterType, fn) { + async withCompanyAdapterPromotionLock(_companyId, _startedByUserId, _adapterType, fn) { // The in-memory store runs on a single event loop, so it needs no real // lock. The pass-through keeps the reaper contract satisfied. return fn(); diff --git a/server/src/__tests__/codex-device-login-service.test.ts b/server/src/__tests__/codex-device-login-service.test.ts index eb6081b57cf..516cd2eb754 100644 --- a/server/src/__tests__/codex-device-login-service.test.ts +++ b/server/src/__tests__/codex-device-login-service.test.ts @@ -66,12 +66,15 @@ const OWNER_B = "user-b"; // the status through a serialized tail, so a test waits for the write to land // before it drives the next step. async function waitForStatus( - store: { get(sessionId: string): Promise }, - sessionId: string, + store: { + getByPublicId(publicSessionId: string, companyId: string): Promise; + }, + publicSessionId: string, + companyId: string, status: AdapterAuthSessionRow["status"], ): Promise { for (let attempt = 0; attempt < 1000; attempt += 1) { - const row = await store.get(sessionId); + const row = await store.getByPublicId(publicSessionId, companyId); if (row?.status === status) return; await new Promise((resolve) => setImmediate(resolve)); } @@ -153,17 +156,21 @@ function createMemoryStore(): AdapterAuthSessionStore & { } { const rows = new Map(); const activeSlots = new Set(); - const slotKey = (companyId: string, adapterType: string) => `${companyId}|${adapterType}`; + // The active company credential slot is scoped to the company, the owner, and + // the adapter, so two owners in one company hold independent slots. + const slotKey = (companyId: string, startedByUserId: string, adapterType: string) => + `${companyId}|${startedByUserId}|${adapterType}`; const isActive = (status: AdapterAuthSessionRow["status"]) => status === "starting" || status === "waiting_for_user" || status === "promoting"; return { rows, async insert(input) { - const key = slotKey(input.companyId, input.adapterType); + const key = slotKey(input.companyId, input.startedByUserId, input.adapterType); if (activeSlots.has(key)) throw new AdapterAuthSessionConflictError(); activeSlots.add(key); rows.set(input.id, { id: input.id, + publicSessionId: input.publicSessionId, companyId: input.companyId, environmentId: input.environmentId, adapterType: input.adapterType, @@ -187,7 +194,8 @@ function createMemoryStore(): AdapterAuthSessionStore & { if (input.failureReason !== undefined) row.failureReason = input.failureReason; if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt; if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt; - if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType)); + if (!isActive(input.status)) + activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType)); }, async compareAndSetStatus(input) { const row = rows.get(input.sessionId); @@ -196,14 +204,25 @@ function createMemoryStore(): AdapterAuthSessionStore & { if (input.failureReason !== undefined) row.failureReason = input.failureReason; if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt; if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt; - if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType)); + if (!isActive(input.status)) + activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType)); return true; }, async get(sessionId) { const row = rows.get(sessionId); return row ? { ...row } : null; }, - async withCompanyAdapterPromotionLock(_companyId, _adapterType, fn) { + async getByPublicId(publicSessionId, companyId) { + // Scope the read to the company and the public session id, so a + // foreign-company lookup reads nothing and the internal id never matches. + for (const row of rows.values()) { + if (row.publicSessionId === publicSessionId && row.companyId === companyId) { + return { ...row }; + } + } + return null; + }, + async withCompanyAdapterPromotionLock(_companyId, _startedByUserId, _adapterType, fn) { // The in-memory store runs on a single event loop, so it needs no real // lock. The pass-through keeps the store contract satisfied. return fn(); @@ -275,15 +294,17 @@ describe("codex device login service", () => { // The prompt is surfaced only after the conditional move to // `waiting_for_user` wins, so wait for that surface, then drain the microtask // that retains the prompt before the owner reads it. - await waitForStatus(store, session.sessionId, "waiting_for_user"); + await waitForStatus(store, session.sessionId, companyId, "waiting_for_user"); await new Promise((resolve) => setImmediate(resolve)); // The owner reads the one-time prompt through the owner read path. - const owner = await service.readOwnerSession(session.sessionId, OWNER_A); + const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); expect(owner?.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE }); + // The read returns the public session id, never the internal row id. + expect(owner?.sessionId).toBe(session.sessionId); // A non-owner never reads the prompt. - const other = await service.readOwnerSession(session.sessionId, OWNER_B); + const other = await service.readOwnerSession(session.sessionId, companyId, OWNER_B); expect(other?.prompt).toBeNull(); const outcome = await completed; @@ -292,15 +313,18 @@ describe("codex device login service", () => { expect(outcome.sandboxDeleteObserved).toBe(true); expect(deleteCalls).toHaveLength(1); expect(promoted).toHaveLength(1); - // The promotion runs with the session and company context, so it resolves the - // company scope and the sole-active-owner check for this exact session. - expect(promotionContexts).toEqual([{ sessionId: session.sessionId, companyId }]); + // The promotion runs with the internal session id and the company context, so + // it resolves the company scope and the sole-active-owner check for this exact + // session. The internal id is not the public session id. + const internalRow = await store.getByPublicId(session.sessionId, companyId); + expect(promotionContexts).toEqual([{ sessionId: internalRow?.id, companyId }]); + expect(internalRow?.id).not.toBe(session.sessionId); // The prompt is one-time: a second owner read returns null. - const secondRead = await service.readOwnerSession(session.sessionId, OWNER_A); + const secondRead = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); expect(secondRead?.prompt).toBeNull(); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("authenticated"); expect(row?.finishedAt).not.toBeNull(); @@ -338,8 +362,9 @@ describe("codex device login service", () => { }, }, }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -350,9 +375,9 @@ describe("codex device login service", () => { expect(outcome.status).toBe("failed"); expect(promoteCalls).toBe(0); expect(deleteCalls).toHaveLength(1); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("failed"); - const failed = await service.readOwnerSession(session.sessionId, OWNER_A); + const failed = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); expect(failed?.failure?.reason).toBe("promotion_failed"); }); @@ -360,8 +385,9 @@ describe("codex device login service", () => { const store = createMemoryStore(); const { runtime, deleteCalls } = createFakeRuntime({ exec: execFailure }); const service = makeService({ store, runtime }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -370,7 +396,7 @@ describe("codex device login service", () => { expect(outcome.status).toBe("failed"); expect(outcome.sandboxDeleteObserved).toBe(true); expect(deleteCalls).toHaveLength(1); - const failed = await service.readOwnerSession(session.sessionId, OWNER_A); + const failed = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); expect(failed?.status).toBe("failed"); expect(failed?.failure?.reason).toBe("login_command_failed"); }); @@ -395,8 +421,9 @@ describe("codex device login service", () => { const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang }); const service = makeService({ store, runtime }); const controller = new AbortController(); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -407,7 +434,7 @@ describe("codex device login service", () => { expect(outcome.status).toBe("cancelled"); expect(outcome.sandboxDeleteObserved).toBe(true); expect(deleteCalls).toHaveLength(1); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("cancelled"); }); @@ -462,8 +489,9 @@ describe("codex device login service", () => { }); const service = makeService({ store, runtime }); const controller = new AbortController(); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -475,10 +503,10 @@ describe("codex device login service", () => { expect(outcome.cleanupPending).toBe(true); expect(outcome.sandboxDeleteObserved).toBe(true); expect(deleteCalls).toHaveLength(1); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("cleanup_pending"); // The public read resolves the retained terminal, never a false-clean. - const publicRead = await service.readOwnerSession(session.sessionId, OWNER_A); + const publicRead = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); expect(publicRead?.status).toBe(terminal); }, ); @@ -499,8 +527,9 @@ describe("codex device login service", () => { }, }, }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -512,9 +541,9 @@ describe("codex device login service", () => { expect(outcome.cleanupPending).toBe(true); expect(outcome.sandboxDeleteObserved).toBe(true); expect(deleteCalls).toHaveLength(1); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("cleanup_pending"); - const publicRead = await service.readOwnerSession(session.sessionId, OWNER_A); + const publicRead = await service.readOwnerSession(session.sessionId, companyId, OWNER_A); expect(publicRead?.status).toBe("failed"); expect(publicRead?.failure?.reason).toBe("promotion_failed"); }); @@ -527,8 +556,9 @@ describe("codex device login service", () => { delete: async () => ({ outcome: "not_found" }), }); const service = makeService({ store, runtime }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -537,7 +567,7 @@ describe("codex device login service", () => { expect(outcome.status).toBe("authenticated"); expect(outcome.cleanupPending).toBe(false); expect(outcome.sandboxDeleteObserved).toBe(true); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("authenticated"); }); @@ -560,19 +590,22 @@ describe("codex device login service", () => { }); const promote = vi.fn(() => {}); const service = makeService({ store, runtime, promotion: { promote } }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, }); // The prompt moves the row to the active `waiting_for_user` state. - await waitForStatus(store, session.sessionId, "waiting_for_user"); + await waitForStatus(store, session.sessionId, companyId, "waiting_for_user"); // The reaper wins the slot: it terminates the row before the promotion claim. + // The status write keys on the internal id, not the public session id. + const seededRow = await store.getByPublicId(session.sessionId, companyId); await store.setStatus({ - sessionId: session.sessionId, + sessionId: seededRow!.id, status: "timed_out", at: new Date(), finishedAt: new Date(), @@ -586,7 +619,7 @@ describe("codex device login service", () => { // The lost claim writes no credential and never authenticates. expect(promote).not.toHaveBeenCalled(); expect(outcome.status).not.toBe("authenticated"); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("timed_out"); // The service still deletes its own sandbox on the lost-claim path. expect(deleteCalls).toHaveLength(1); @@ -616,18 +649,18 @@ describe("codex device login service", () => { adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, }); - await waitForStatus(store, session.sessionId, "waiting_for_user"); + await waitForStatus(store, session.sessionId, companyId, "waiting_for_user"); // A non-owner cannot cancel the session. - expect(await service.cancelOwnerSession(session.sessionId, OWNER_B)).toBeNull(); + expect(await service.cancelOwnerSession(session.sessionId, companyId, OWNER_B)).toBeNull(); // The owner cancel resolves the public terminal status at once. - const cancelled = await service.cancelOwnerSession(session.sessionId, OWNER_A); + const cancelled = await service.cancelOwnerSession(session.sessionId, companyId, OWNER_A); expect(cancelled?.status).toBe("cancelled"); // The row holds the internal cleanup_pending state that encodes the // cancelled terminal, so the reaper deletes the sandbox and finalizes it. - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("cleanup_pending"); expect(row?.finishedAt).not.toBeNull(); @@ -662,19 +695,20 @@ describe("codex device login service", () => { }); const { runtime } = createFakeRuntime({ exec: execSuccess, authBytes: Buffer.from("{}") }); const service = makeService({ store, runtime, promotion: { promote } }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, }); - await waitForStatus(store, session.sessionId, "promoting"); + await waitForStatus(store, session.sessionId, companyId, "promoting"); // The cancel skips the promoting row, so the credential write finishes. The // public projection of a promoting row is `waiting_for_user`. - const result = await service.cancelOwnerSession(session.sessionId, OWNER_A); + const result = await service.cancelOwnerSession(session.sessionId, companyId, OWNER_A); expect(result?.status).toBe("waiting_for_user"); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("promoting"); // Release the promotion, so the run reaches its own terminal. @@ -691,8 +725,9 @@ describe("codex device login service", () => { const store = createMemoryStore(); const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang }); const service = makeService({ store, runtime }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -706,7 +741,7 @@ describe("codex device login service", () => { // claim. await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS - 1); expect(settled).toBe(false); - const midRow = await store.get(session.sessionId); + const midRow = await store.getByPublicId(session.sessionId, companyId); expect(["starting", "waiting_for_user"]).toContain(midRow?.status); // The last millisecond fires the timeout. @@ -715,7 +750,7 @@ describe("codex device login service", () => { expect(outcome.status).toBe("timed_out"); expect(outcome.sandboxDeleteObserved).toBe(true); expect(deleteCalls).toHaveLength(1); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("timed_out"); } finally { vi.useRealTimers(); @@ -733,8 +768,9 @@ describe("codex device login service", () => { }, }); const service = makeService({ store, runtime }); + const companyId = randomUUID(); const { session, completed } = await service.start({ - companyId: randomUUID(), + companyId, environmentId: randomUUID(), adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, @@ -745,7 +781,7 @@ describe("codex device login service", () => { expect(outcome.cleanupPending).toBe(true); expect(outcome.sandboxDeleteObserved).toBe(true); expect(deleteCalls).toHaveLength(1); - const row = await store.get(session.sessionId); + const row = await store.getByPublicId(session.sessionId, companyId); expect(row?.status).toBe("cleanup_pending"); } finally { vi.useRealTimers(); @@ -887,73 +923,112 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg return environmentId; } - it.each([ - { name: "two owners, same environment" }, - { name: "one owner, two environments" }, - ])( - "returns one session, one lease, and one 409 for concurrent starts ($name)", - async ({ name }) => { - const { companyId, environmentId: environmentA } = await seedCompanyEnvironment(); - const twoOwners = name.startsWith("two owners"); - const environmentB = twoOwners ? environmentA : await seedEnvironment(companyId); - const ownerB = twoOwners ? OWNER_B : OWNER_A; + it("lets two owners in one company each hold an active session", async () => { + // The active slot is scoped to the company, the owner, and the adapter. Two + // owners in one company hold independent slots, so both concurrent starts + // succeed and each acquires one lease. + const { companyId, environmentId } = await seedCompanyEnvironment(); + const store = createDbAdapterAuthSessionStore(db); + const { runtime, acquisitions } = createFakeRuntime({ exec: execHang }); + const service = makeService({ store, runtime }); + const controller = new AbortController(); - const store = createDbAdapterAuthSessionStore(db); - const { runtime, acquisitions } = createFakeRuntime({ exec: execHang }); - const service = makeService({ store, runtime }); - const controller = new AbortController(); + const results = await Promise.allSettled([ + service.start({ + companyId, + environmentId, + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_A, + signal: controller.signal, + }), + service.start({ + companyId, + environmentId, + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_B, + signal: controller.signal, + }), + ]); - const results = await Promise.allSettled([ - service.start({ - companyId, - environmentId: environmentA, - adapterType: ADAPTER_TYPE, - startedByUserId: OWNER_A, - signal: controller.signal, - }), - service.start({ - companyId, - environmentId: environmentB, - adapterType: ADAPTER_TYPE, - startedByUserId: ownerB, - signal: controller.signal, - }), - ]); + const fulfilled = results.filter( + (result): result is PromiseFulfilledResult>> => + result.status === "fulfilled", + ); + expect(fulfilled).toHaveLength(2); + expect(acquisitions).toHaveLength(2); + const rows = await db + .select() + .from(adapterAuthSessions) + .where(eq(adapterAuthSessions.companyId, companyId)); + expect(rows).toHaveLength(2); - const fulfilled = results.filter( - (result): result is PromiseFulfilledResult>> => - result.status === "fulfilled", - ); - const rejected = results.filter( - (result): result is PromiseRejectedResult => result.status === "rejected", - ); - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - const conflict = rejected[0]!.reason; - expect(conflict).toBeInstanceOf(AdapterAuthSessionConflictError); - expect(conflict.statusCode).toBe(409); + // Release the surviving runs so the hanging login commands end. + controller.abort(); + await Promise.all(fulfilled.map((result) => result.value.completed)); + }); - // Exactly one start acquired a lease; the losing start never acquired. - expect(acquisitions).toHaveLength(1); - const rows = await db - .select() - .from(adapterAuthSessions) - .where(eq(adapterAuthSessions.companyId, companyId)); - expect(rows).toHaveLength(1); + it("returns one session, one lease, and one 409 when one owner starts twice", async () => { + // One owner starting twice in two environments conflicts on the active slot. + // The slot dropped the environment term, so the second environment does not + // grant a second active session. + const { companyId, environmentId: environmentA } = await seedCompanyEnvironment(); + const environmentB = await seedEnvironment(companyId); - // Release the surviving run so the hanging login command ends. - controller.abort(); - await fulfilled[0]!.value.completed; - }, - ); + const store = createDbAdapterAuthSessionStore(db); + const { runtime, acquisitions } = createFakeRuntime({ exec: execHang }); + const service = makeService({ store, runtime }); + const controller = new AbortController(); - it("rejects a second start with a 409 while the first login holds the promotion claim", async () => { + const results = await Promise.allSettled([ + service.start({ + companyId, + environmentId: environmentA, + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_A, + signal: controller.signal, + }), + service.start({ + companyId, + environmentId: environmentB, + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_A, + signal: controller.signal, + }), + ]); + + const fulfilled = results.filter( + (result): result is PromiseFulfilledResult>> => + result.status === "fulfilled", + ); + const rejected = results.filter( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + const conflict = rejected[0]!.reason; + expect(conflict).toBeInstanceOf(AdapterAuthSessionConflictError); + expect(conflict.statusCode).toBe(409); + + // Exactly one start acquired a lease; the losing start never acquired. + expect(acquisitions).toHaveLength(1); + const rows = await db + .select() + .from(adapterAuthSessions) + .where(eq(adapterAuthSessions.companyId, companyId)); + expect(rows).toHaveLength(1); + + // Release the surviving run so the hanging login command ends. + controller.abort(); + await fulfilled[0]!.value.completed; + }); + + it("rejects the same owner's second start with a 409 while the first holds the promotion claim", async () => { const { companyId, environmentId } = await seedCompanyEnvironment(); const store = createDbAdapterAuthSessionStore(db); const { runtime } = createFakeRuntime({ exec: execSuccess, authBytes: Buffer.from("{}") }); // A promotion that never resolves. The first login stays in `promoting`, so - // it holds the active company slot through the claim window. + // it holds the active slot through the claim window. let releasePromotion!: () => void; const promotionGate = new Promise((resolve) => { releasePromotion = resolve; @@ -972,16 +1047,16 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg }); // The first login reaches the `promoting` claim and holds it. - await waitForStatus(store, first.session.sessionId, "promoting"); + await waitForStatus(store, first.session.sessionId, companyId, "promoting"); - // A second start for the same company and adapter conflicts on the active - // slot, even though the first login is mid-promotion. + // A second start for the same company, owner, and adapter conflicts on the + // active slot, even though the first login is mid-promotion. await expect( service.start({ companyId, environmentId, adapterType: ADAPTER_TYPE, - startedByUserId: OWNER_B, + startedByUserId: OWNER_A, }), ).rejects.toBeInstanceOf(AdapterAuthSessionConflictError); @@ -1003,6 +1078,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg environmentId, adapterType: ADAPTER_TYPE, startedByUserId: OWNER_A, + publicSessionId: randomUUID(), providerLeaseId: `lease-${sessionId}`, status: "promoting", expiresAt: past, @@ -1033,7 +1109,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg const sectionActive = new Promise((resolve) => { markSectionActive = resolve; }); - const promotion = store.withCompanyAdapterPromotionLock(companyId, ADAPTER_TYPE, async () => { + const promotion = store.withCompanyAdapterPromotionLock(companyId, OWNER_A, ADAPTER_TYPE, async () => { markSectionActive(); await sectionGate; // Decision H: the write proceeds only while the session still owns the slot. @@ -1078,7 +1154,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg // A delayed promotion now runs its section. The ownership check reads the // reclaimed row, so it writes no credential and the terminal claim fails. let wroteCredential = false; - await store.withCompanyAdapterPromotionLock(companyId, ADAPTER_TYPE, async () => { + await store.withCompanyAdapterPromotionLock(companyId, OWNER_A, ADAPTER_TYPE, async () => { const row = await store.get(sessionId); if (row?.status === "promoting" && row.companyId === companyId) { wroteCredential = true; @@ -1122,8 +1198,8 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg startedByUserId: OWNER_A, signal: controller.signal, }); - const sessionId = started.session.sessionId; - await waitForStatus(store, sessionId, "starting"); + const publicSessionId = started.session.sessionId; + await waitForStatus(store, publicSessionId, companyId, "starting"); // The reaper reclaims the expired row and times it out. const { runtime: reaperRuntime } = createReaperRuntime(); @@ -1134,7 +1210,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg }); const sweep = await reaper.sweep(); expect(sweep.expiredTimedOut).toBe(1); - expect((await store.get(sessionId))?.status).toBe("timed_out"); + expect((await store.getByPublicId(publicSessionId, companyId))?.status).toBe("timed_out"); // The delayed prompt arrives now. The conditional transition must not revive // the reaped row, and the owner must not read a prompt for it. @@ -1142,12 +1218,94 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); - const owner = await service.readOwnerSession(sessionId, OWNER_A); - expect((await store.get(sessionId))?.status).toBe("timed_out"); + const owner = await service.readOwnerSession(publicSessionId, companyId, OWNER_A); + expect((await store.getByPublicId(publicSessionId, companyId))?.status).toBe("timed_out"); expect(owner?.prompt ?? null).toBeNull(); // End the run so no timer survives. controller.abort(); await started.completed; }); + + describe("public session id lookup", () => { + // Start one session and read its rows. The service returns the public session + // id, and the store keeps the internal id private. + async function startOneSession(companyId: string, environmentId: string) { + const store = createDbAdapterAuthSessionStore(db); + const controller = new AbortController(); + const { runtime } = createFakeRuntime({ exec: execHang }); + const service = makeService({ store, runtime }); + const started = await service.start({ + companyId, + environmentId, + adapterType: ADAPTER_TYPE, + startedByUserId: OWNER_A, + signal: controller.signal, + }); + const publicSessionId = started.session.sessionId; + const row = await store.getByPublicId(publicSessionId, companyId); + return { store, service, controller, started, publicSessionId, row }; + } + + it("reads a session by its public id in the same company", async () => { + const { companyId, environmentId } = await seedCompanyEnvironment(); + const { store, controller, started, publicSessionId, row } = await startOneSession( + companyId, + environmentId, + ); + + // The lookup by the public id succeeds in the same company. + expect(row).not.toBeNull(); + expect(row?.publicSessionId).toBe(publicSessionId); + // The internal primary-key id is a distinct value, never the public id. + expect(row?.id).toBeTruthy(); + expect(row?.id).not.toBe(publicSessionId); + // The API-facing row read stays scoped: the same public id resolves the + // same row on a second read. + const again = await store.getByPublicId(publicSessionId, companyId); + expect(again?.id).toBe(row?.id); + + controller.abort(); + await started.completed; + }); + + it("does not read a session for a foreign company", async () => { + const { companyId, environmentId } = await seedCompanyEnvironment(); + const { store, controller, started, publicSessionId } = await startOneSession( + companyId, + environmentId, + ); + + // A different company id never resolves the row, even with the exact public + // session id. The predicate carries the company id, so a query never keys on + // the public session id alone. + const foreignCompanyId = randomUUID(); + expect(await store.getByPublicId(publicSessionId, foreignCompanyId)).toBeNull(); + + controller.abort(); + await started.completed; + }); + + it("never accepts the internal row id as an API identifier", async () => { + const { companyId, environmentId } = await seedCompanyEnvironment(); + const { store, service, controller, started, publicSessionId, row } = + await startOneSession(companyId, environmentId); + const internalId = row!.id; + + // The store never resolves a row by the internal id through the public-id + // path, even within the same company. + expect(await store.getByPublicId(internalId, companyId)).toBeNull(); + + // The owner read returns the public session id, never the internal id. + const owner = await service.readOwnerSession(publicSessionId, companyId, OWNER_A); + expect(owner?.sessionId).toBe(publicSessionId); + expect(owner?.sessionId).not.toBe(internalId); + + // The owner read rejects the internal id as an API identifier. + expect(await service.readOwnerSession(internalId, companyId, OWNER_A)).toBeNull(); + + controller.abort(); + await started.completed; + }); + }); }); diff --git a/server/src/adapters/plugin-loader.test.ts b/server/src/adapters/plugin-loader.test.ts new file mode 100644 index 00000000000..f2319131a8f --- /dev/null +++ b/server/src/adapters/plugin-loader.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import type { AdapterLoginCapability } from "@paperclipai/adapter-utils"; +import { validateAdapterModule } from "./plugin-loader.js"; + +// A minimal external adapter module. The loader calls `createServerAdapter()` +// and then validates the returned module. Each test controls the returned +// `loginCapability` to check the fail-closed rule at load time. +function makeModule(loginCapability?: unknown) { + return { + createServerAdapter: () => ({ + type: "vendor_local", + execute: async () => ({}), + testEnvironment: async () => ({}), + ...(loginCapability === undefined ? {} : { loginCapability }), + }), + }; +} + +const validLoginCapability: AdapterLoginCapability = { + panelMode: "displayed_code", + sandboxTransport: "streamed_exec", + timeoutPolicy: "caller_bounded", + getCommand: () => "vendor login", + parsePrompt: () => null, +}; + +describe("validateAdapterModule login capability", () => { + it("loads an adapter with no login capability", () => { + expect(() => validateAdapterModule(makeModule(), "vendor-pkg")).not.toThrow(); + }); + + it("loads an adapter with a well-formed login capability", () => { + expect(() => validateAdapterModule(makeModule(validLoginCapability), "vendor-pkg")).not.toThrow(); + }); + + it("rejects an adapter with a malformed login capability", () => { + const bad = { ...validLoginCapability, panelMode: "hidden_code" }; + expect(() => validateAdapterModule(makeModule(bad), "vendor-pkg")).toThrow( + /invalid login capability/, + ); + }); + + it("rejects an adapter with a non-object login capability", () => { + expect(() => validateAdapterModule(makeModule("displayed_code"), "vendor-pkg")).toThrow( + /invalid login capability/, + ); + }); +}); diff --git a/server/src/adapters/plugin-loader.ts b/server/src/adapters/plugin-loader.ts index c01fedeac43..236e8ddd50f 100644 --- a/server/src/adapters/plugin-loader.ts +++ b/server/src/adapters/plugin-loader.ts @@ -13,6 +13,7 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type { ServerAdapterModule } from "./types.js"; +import { validateAdapterLoginCapability } from "@paperclipai/adapter-utils"; import { logger } from "../middleware/logger.js"; import { @@ -145,7 +146,7 @@ function extractUiParserSource( // Load / reload // --------------------------------------------------------------------------- -function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule { +export function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule { const m = mod as Record; const createServerAdapter = m.createServerAdapter; if (typeof createServerAdapter !== "function") { @@ -161,6 +162,19 @@ function validateAdapterModule(mod: unknown, packageName: string): ServerAdapter `createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`, ); } + + // Fail closed on a malformed login capability. The validator throws a clear + // error, so the loader rejects the adapter instead of loading it with a + // partial capability. + try { + validateAdapterLoginCapability(adapterModule); + } catch (err) { + throw new Error( + `createServerAdapter() from "${packageName}" returned an invalid login capability: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + return adapterModule; } diff --git a/server/src/adapters/registry.test.ts b/server/src/adapters/registry.test.ts new file mode 100644 index 00000000000..cb2c2a41c5c --- /dev/null +++ b/server/src/adapters/registry.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { assertValidAdapterLoginCapability } from "@paperclipai/adapter-utils"; +import { requireServerAdapter } from "./registry.js"; + +// The registry registers a login capability for the two built-in interactive +// adapters. The test checks the scalar values and the presence of the required +// callbacks. It also runs the shared validator, so the built-in capabilities +// obey the same fail-closed contract as an external adapter. + +describe("built-in adapter login capabilities", () => { + it("registers the Codex device-login capability", () => { + const capability = requireServerAdapter("codex_local").loginCapability; + expect(capability).toBeDefined(); + if (!capability) return; + expect(capability.panelMode).toBe("displayed_code"); + expect(capability.sandboxTransport).toBe("streamed_exec"); + expect(capability.timeoutPolicy).toBe("caller_bounded"); + expect(capability.completionClaim).toBeUndefined(); + expect(typeof capability.getCommand).toBe("function"); + expect(typeof capability.parsePrompt).toBe("function"); + expect(() => assertValidAdapterLoginCapability(capability, "codex_local")).not.toThrow(); + }); + + it("registers the Claude setup-token capability", () => { + const capability = requireServerAdapter("claude_local").loginCapability; + expect(capability).toBeDefined(); + if (!capability) return; + expect(capability.panelMode).toBe("submitted_browser_code"); + expect(capability.sandboxTransport).toBe("pseudo_terminal"); + expect(capability.timeoutPolicy).toBe("fixed"); + expect(capability.completionClaim).toBe("storedSessionId"); + expect(typeof capability.getCommand).toBe("function"); + expect(typeof capability.parsePrompt).toBe("function"); + expect(typeof capability.captureCredential).toBe("function"); + expect(() => assertValidAdapterLoginCapability(capability, "claude_local")).not.toThrow(); + }); +}); diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 60d84616885..dd5f539bcb7 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -9,6 +9,7 @@ import { buildSandboxNpmInstallCommand, getAdapterSessionManagement, } from "@paperclipai/adapter-utils"; +import type { AdapterLoginCapability } from "@paperclipai/adapter-utils"; import { execute as claudeExecute, listClaudeSkills, @@ -19,6 +20,9 @@ import { sessionCodec as claudeSessionCodec, getQuotaWindows as claudeGetQuotaWindows, getConfigSchema as getClaudeConfigSchema, + CLAUDE_SETUP_TOKEN_COMMAND, + parseSetupTokenPrompt, + parseSetupTokenCredential, } from "@paperclipai/adapter-claude-local/server"; import { agentConfigurationDoc as claudeAgentConfigurationDoc, @@ -33,6 +37,8 @@ import { sessionCodec as codexSessionCodec, getQuotaWindows as codexGetQuotaWindows, getConfigSchema as getCodexConfigSchema, + CODEX_DEVICE_LOGIN_COMMAND, + parseDeviceLoginPrompt, } from "@paperclipai/adapter-codex-local/server"; import { agentConfigurationDoc as codexAgentConfigurationDoc, @@ -181,6 +187,43 @@ The standalone ACPX adapter has been retired. Use: Paperclip keeps this tombstone registered so stale acpx_local rows fail clearly instead of falling back to the process adapter. `; +// The Claude interactive login capability. Claude runs `claude setup-token` on a +// real pseudo-terminal. The user pastes a browser code back into the flow. The +// flow uses a fixed host-side timeout and records a stored session identifier on +// success. The capability data holds no secret; the callbacks return runtime +// values only. +const claudeLoginCapability: AdapterLoginCapability = { + panelMode: "submitted_browser_code", + sandboxTransport: "pseudo_terminal", + timeoutPolicy: "fixed", + getCommand: () => CLAUDE_SETUP_TOKEN_COMMAND, + parsePrompt: (output) => { + const prompt = parseSetupTokenPrompt(output); + return prompt ? { url: prompt.url } : null; + }, + captureCredential: (output) => { + const token = parseSetupTokenCredential(output); + return token === null ? null : Buffer.from(token, "utf8"); + }, + completionClaim: "storedSessionId", +}; + +// The Codex interactive login capability. Codex runs `codex login --device-auth` +// over the streamed exec channel. The flow shows a one-time code that the user +// enters in the browser. The caller sets the host-side timeout. The device-login +// flow writes its credential inside the sandbox, so the capability declares no +// terminal credential capture and no completion claim. +const codexLoginCapability: AdapterLoginCapability = { + panelMode: "displayed_code", + sandboxTransport: "streamed_exec", + timeoutPolicy: "caller_bounded", + getCommand: () => CODEX_DEVICE_LOGIN_COMMAND, + parsePrompt: (output) => { + const prompt = parseDeviceLoginPrompt(output); + return prompt ? { url: prompt.url, code: prompt.code } : null; + }, +}; + const claudeLocalAdapter: ServerAdapterModule = { type: "claude_local", execute: stampClaudeAgentIdHeader(claudeExecute), @@ -210,6 +253,7 @@ const claudeLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: claudeAgentConfigurationDoc, getConfigSchema: getClaudeConfigSchema, getQuotaWindows: claudeGetQuotaWindows, + loginCapability: claudeLoginCapability, }; const acpxLocalAdapter: ServerAdapterModule = { @@ -282,6 +326,7 @@ const codexLocalAdapter: ServerAdapterModule = { agentConfigurationDoc: codexAgentConfigurationDoc, getConfigSchema: getCodexConfigSchema, getQuotaWindows: codexGetQuotaWindows, + loginCapability: codexLoginCapability, }; const cursorLocalAdapter: ServerAdapterModule = { diff --git a/server/src/app.ts b/server/src/app.ts index 90e51c4a416..3731338c656 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -493,12 +493,10 @@ export async function createApp( confidentialEdgeTlsTerminated: setupTokenLoginEdgeTlsTerminated, setupTokenLogin: setupTokenLoginTransport, onSetupTokenLoginService: (service) => { + // Capture the service, so the graceful-shutdown hook cancels every live + // session and releases each lease. The standalone scheduled reaper owns + // the startup and interval lease cleanup now (SR-4). setupTokenLoginService = service; - // Startup reaper (SR-4): release any lease whose login session is - // terminal or past its deadline after a restart. The DB is ready here. - void service.reap().catch((err) => { - logger.error({ err }, "Setup-token login startup reaper failed"); - }); }, }), ); diff --git a/server/src/index.ts b/server/src/index.ts index eca86caf519..cd4a49194f1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -72,6 +72,7 @@ import { createCodexDeviceLoginReaper, createProductionLoginSessionReaperRuntime, } from "./services/codex-device-login-reaper.js"; +import { createProductionSetupTokenReaper } from "./services/setup-token-reaper.js"; import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js"; import { parseAdapterRegistryEnv, @@ -1166,6 +1167,32 @@ export async function startServer(): Promise { })); }; + // The restart-safe cleanup backstop for the Claude setup-token login flow. It + // runs on startup and on the scheduler interval, so a sandbox lease survives a + // server restart and a release failure. It releases any lease whose login + // session is terminal, past its deadline, or already consumed. + const setupTokenReaper = createProductionSetupTokenReaper({ + db: db as any, + environmentRuntime: environmentRuntimeService(db as any, { pluginWorkerManager }), + log: (line) => logger.info(line), + }); + const logSetupTokenReaperResult = ( + result: Awaited>, + ) => { + if (result.released > 0 || result.failed > 0) { + logger.info(result, "setup-token login reaper released leases"); + } + }; + const scheduleSetupTokenReaperSweep = () => { + if (heartbeatSchedulerStopped) return; + trackHeartbeatSchedulerWork(setupTokenReaper + .sweep() + .then(logSetupTokenReaperResult) + .catch((err) => { + logger.error({ err }, "setup-token login reaper sweep failed"); + })); + }; + const tools = toolAccessService(db as any, { deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, @@ -1303,6 +1330,15 @@ export async function startServer(): Promise { logger.error({ err }, "startup adapter login reaper sweep failed"); }); + // Run the setup-token login reaper once at startup, so a login sandbox lease + // that outlived a server restart releases before timer ticks start. + await setupTokenReaper + .sweep() + .then(logSetupTokenReaperResult) + .catch((err) => { + logger.error({ err }, "startup setup-token login reaper sweep failed"); + }); + // Retry any orphan sandbox teardown left by a failed acquire before a server // restart, so a leaked sandbox does not stay allocated across the restart. await runEnvironmentLeaseCleanupSweep(0); @@ -1366,6 +1402,7 @@ export async function startServer(): Promise { scheduleMergedPullRequestConfirmationSweep(); scheduleTerminalWorkspaceSweep(); scheduleAdapterLoginReaperSweep(); + scheduleSetupTokenReaperSweep(); scheduleEnvironmentLeaseCleanupSweep(); if (heartbeatSchedulerStopped) return; diff --git a/server/src/routes/adapter-login-route-spine.ts b/server/src/routes/adapter-login-route-spine.ts new file mode 100644 index 00000000000..1b5dc86e275 --- /dev/null +++ b/server/src/routes/adapter-login-route-spine.ts @@ -0,0 +1,107 @@ +import type { Request, Response } from "express"; +import type { z } from "zod"; + +/** + * The result of the shared adapter login start-route spine. The spine returns it + * when every guard passes. `ownerUserId` is the immutable session owner from the + * actor. `data` is the validated request body. + */ +export interface AdapterLoginStartResolution { + ownerUserId: string; + data: TData; +} + +/** + * The injected steps of the shared adapter login start-route spine. The Codex + * device login start route and the Claude setup-token login start route both + * provide them. Each step holds the per-flow rule; the spine holds the shared + * order. + */ +export interface AdapterLoginStartSpineInput { + req: Request; + res: Response; + /** + * Derives the immutable session owner from the actor. It also runs the company + * access check. It throws a mapped HTTP error for a forbidden actor. The shared + * error handler maps the thrown error to the response. + */ + deriveOwner: () => Promise | string; + /** + * The strict request schema. `.strict()` rejects an unknown field. The spine + * validates the request body against it before any session or lease side + * effect. + */ + requestSchema: z.ZodType; + /** The fixed 400 text for an invalid request. Each route keeps its own text. */ + invalidRequestError: string; + /** + * Merges these fields into the request body before the parse. The Codex route + * injects the adapter type from the path, so the client body needs no adapter + * type field. + */ + requestOverrides?: Record; + /** + * Runs an optional per-flow guard after the owner derivation and before the + * request validation. The Codex route checks the path adapter type here. It + * throws a mapped HTTP error to reject the request. + */ + guardBeforeValidate?: () => void | Promise; + /** + * Runs an optional per-flow guard after the validation and before the sandbox + * check. The Claude route checks the body adapter type and the transport + * readiness here. It returns `true` when it sends a response; the spine then + * stops and returns null. + */ + guardAfterValidate?: (data: TData) => boolean | Promise; + /** + * Checks the sandbox environment. It throws a mapped HTTP error for a missing, + * archived, non-sandbox, or foreign environment. + */ + assertSandbox: (data: TData) => Promise; +} + +/** + * Runs the shared start-route spine for an adapter login. It runs the same + * ordered steps for both start routes, and it runs every step before any session + * or lease side effect: + * + * 1. It derives the session owner and runs the company access check. + * 2. It runs the optional pre-validation guard. + * 3. It validates the request body with the strict schema. It sends the fixed + * 400 and returns null on a failure. + * 4. It runs the optional post-validation guard. It returns null when the guard + * sends a response. + * 5. It checks the sandbox environment. + * + * It returns the owner and the validated body when every step passes. It returns + * null when a step sends a response; the caller must then stop. + */ +export async function runAdapterLoginStartSpine( + input: AdapterLoginStartSpineInput, +): Promise | null> { + const ownerUserId = await input.deriveOwner(); + + if (input.guardBeforeValidate) { + await input.guardBeforeValidate(); + } + + const body = + input.req.body && typeof input.req.body === "object" + ? (input.req.body as Record) + : {}; + const candidate = input.requestOverrides ? { ...body, ...input.requestOverrides } : body; + const parsed = input.requestSchema.safeParse(candidate); + if (!parsed.success) { + input.res.status(400).json({ error: input.invalidRequestError }); + return null; + } + const data = parsed.data; + + if (input.guardAfterValidate) { + const stop = await input.guardAfterValidate(data); + if (stop) return null; + } + + await input.assertSandbox(data); + return { ownerUserId, data }; +} diff --git a/server/src/routes/adapters.test.ts b/server/src/routes/adapters.test.ts new file mode 100644 index 00000000000..17112f6180b --- /dev/null +++ b/server/src/routes/adapters.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import type { AdapterLoginCapability, ServerAdapterModule } from "@paperclipai/adapter-utils"; +import { buildAdapterCapabilities } from "./adapters.js"; + +// The adapter listing projects the safe scalar login fields to the client. The +// projection carries the panel mode, the sandbox transport, and the timeout +// policy. It never carries a function member or a secret. An adapter with no +// login capability projects no `login` object. + +function makeAdapter(overrides: Partial = {}): ServerAdapterModule { + return { + type: "vendor_local", + execute: async () => { + throw new Error("not used"); + }, + testEnvironment: async () => { + throw new Error("not used"); + }, + ...overrides, + } as ServerAdapterModule; +} + +const displayedCodeLogin: AdapterLoginCapability = { + panelMode: "displayed_code", + sandboxTransport: "streamed_exec", + timeoutPolicy: "caller_bounded", + getCommand: () => "vendor login", + parsePrompt: () => null, +}; + +describe("buildAdapterCapabilities login projection", () => { + it("projects the safe scalar login fields", () => { + const caps = buildAdapterCapabilities(makeAdapter({ loginCapability: displayedCodeLogin })); + expect(caps.login).toEqual({ + panelMode: "displayed_code", + sandboxTransport: "streamed_exec", + timeoutPolicy: "caller_bounded", + }); + }); + + it("omits the login object when the adapter declares no capability", () => { + const caps = buildAdapterCapabilities(makeAdapter()); + expect(caps.login).toBeUndefined(); + }); + + it("never projects the function members or a completion claim", () => { + const caps = buildAdapterCapabilities( + makeAdapter({ + loginCapability: { + panelMode: "submitted_browser_code", + sandboxTransport: "pseudo_terminal", + timeoutPolicy: "fixed", + getCommand: () => "vendor setup-token", + parsePrompt: () => null, + captureCredential: () => null, + completionClaim: "storedSessionId", + }, + }), + ); + expect(caps.login).toEqual({ + panelMode: "submitted_browser_code", + sandboxTransport: "pseudo_terminal", + timeoutPolicy: "fixed", + }); + expect(caps.login).not.toHaveProperty("getCommand"); + expect(caps.login).not.toHaveProperty("parsePrompt"); + expect(caps.login).not.toHaveProperty("captureCredential"); + expect(caps.login).not.toHaveProperty("completionClaim"); + }); +}); diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 4be1b5115d5..0fea057e552 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -41,6 +41,11 @@ import { } from "../services/adapter-plugin-store.js"; import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; +import type { + AdapterLoginPanelMode, + AdapterLoginSandboxTransport, + AdapterLoginTimeoutPolicy, +} from "@paperclipai/adapter-utils"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; import { forbidden } from "../errors.js"; @@ -79,6 +84,18 @@ interface AdapterInstallRequest { version?: string; } +/** + * The safe scalar login fields the adapter listing projects to the client. It + * carries only the panel mode, the sandbox transport, and the timeout policy. + * It carries no function member and no secret. The user interface reads it to + * pick the login flow and the login panel. + */ +interface AdapterLoginProjection { + panelMode: AdapterLoginPanelMode; + sandboxTransport: AdapterLoginSandboxTransport; + timeoutPolicy: AdapterLoginTimeoutPolicy; +} + interface AdapterCapabilities { supportsInstructionsBundle: boolean; supportsSkills: boolean; @@ -86,6 +103,11 @@ interface AdapterCapabilities { requiresMaterializedRuntimeSkills: boolean; supportsModelProfiles: boolean; supportsAcp: boolean; + /** + * The projected login capability. It is present only when the adapter + * declares an interactive login capability. It is absent otherwise. + */ + login?: AdapterLoginProjection; } interface AdapterInfo { @@ -134,7 +156,13 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string } } -function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { +/** + * Build the client capability view for one adapter. The login projection carries + * only the safe scalar fields; it drops the function members and the completion + * claim, so the response holds no secret and no code. + */ +export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { + const login = adapter.loginCapability; return { supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), @@ -142,6 +170,15 @@ function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabili requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles), supportsAcp: Boolean(adapter.acp), + ...(login + ? { + login: { + panelMode: login.panelMode, + sandboxTransport: login.sandboxTransport, + timeoutPolicy: login.timeoutPolicy, + }, + } + : {}), }; } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index a86780c6a0e..14e4a6a49b4 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -29,6 +29,7 @@ import { updateAgentSchema, supportedEnvironmentDriversForAdapter, LOW_TRUST_REVIEW_PRESET, + startAdapterAuthSessionRequestSchema, startClaudeSetupTokenSessionRequestSchema, submitBrowserCodeRequestSchema, } from "@paperclipai/shared"; @@ -61,6 +62,7 @@ import { import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js"; import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js"; +import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js"; import { assertNoAgentHostWorkspaceCommandMutation, collectAgentAdapterWorkspaceCommandPaths, @@ -140,7 +142,6 @@ import { } from "@paperclipai/adapter-codex-local/server"; import { AdapterAuthSessionConflictError, - CODEX_DEVICE_LOGIN_ADAPTER_TYPE, createCodexDeviceLoginService, createDbAdapterAuthSessionStore, createProductionLoginSessionRuntime, @@ -362,12 +363,10 @@ export function agentRoutes( companyId: string; ownerUserId: string; adapterType: string; - environmentId: string; }): boolean => row.companyId === identity.companyId && row.ownerUserId === identity.ownerUserId && - row.adapterType === identity.adapterType && - row.environmentId === identity.environmentId; + row.adapterType === identity.adapterType; const inMemorySetupTokenCleanupStore: SetupTokenCleanupStore = { async record(record): Promise { setupTokenCleanupRows.set(record.sessionId, { ...record }); @@ -507,6 +506,7 @@ export function agentRoutes( // after the fence; the lock spans the whole section. const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock( context.companyId, + context.startedByUserId, context.adapterType, () => promoteDeviceLoginCredential({ @@ -1167,9 +1167,20 @@ export function agentRoutes( return userId; } - // The device-login flow supports only the Codex adapter. Reject any other type. - function assertCodexLoginAdapter(type: string): void { - if (type !== CODEX_DEVICE_LOGIN_ADAPTER_TYPE) { + // Read the interactive login capability the registry declares for an adapter + // type. Return null when the adapter declares no capability, so a guard fails + // closed on the absent case. + function getRegistryLoginCapability(type: string) { + return findActiveServerAdapter(type)?.loginCapability ?? null; + } + + // The device-login route drives a login over the streamed exec channel. It + // serves any adapter whose registry login capability declares that transport. + // The guard reads the capability, not the adapter name, so a new adapter with + // the same transport passes with no code change. It rejects an adapter with no + // matching capability with a fixed 400. + function assertStreamedExecLoginAdapter(type: string): void { + if (getRegistryLoginCapability(type)?.sandboxTransport !== "streamed_exec") { throw badRequest(`Adapter "${type}" does not support a device login.`); } } @@ -1236,7 +1247,7 @@ export function agentRoutes( const resolved = provider ? await resolvePluginSandboxProviderDriverByKey({ db, driverKey: provider }) : null; - if (!resolved?.driver.supportsSetupTokenLogin) { + if (!resolved?.driver.supportsLoginPty) { throw unprocessable(SETUP_TOKEN_PROVIDER_UNSUPPORTED, { code: SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE, }); @@ -1251,19 +1262,17 @@ export function agentRoutes( async function readOwnerLoginSession( companyId: string, adapterType: string, - sessionId: string, + publicSessionId: string, requestingUserId: string, ): Promise { - const row = await adapterLoginStore.get(sessionId); - if ( - !row || - row.companyId !== companyId || - row.adapterType !== adapterType || - row.startedByUserId !== requestingUserId - ) { + // Read by the public session id, scoped to the company. The store predicate + // already carries the company id, so a foreign-company caller reads nothing + // and the internal row id never matches. Keep the adapter and owner checks. + const row = await adapterLoginStore.getByPublicId(publicSessionId, companyId); + if (!row || row.adapterType !== adapterType || row.startedByUserId !== requestingUserId) { return null; } - return adapterLoginService.readOwnerSession(sessionId, requestingUserId); + return adapterLoginService.readOwnerSession(publicSessionId, companyId, requestingUserId); } async function assertCanReadConfigurations(req: Request, companyId: string) { @@ -2494,33 +2503,35 @@ export function agentRoutes( async (req, res) => { const companyId = req.params.companyId as string; const type = req.params.type as string; - const startedByUserId = await assertCanManageAdapterLogin(req, companyId); - assertCodexLoginAdapter(type); - const environmentId = - typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0 - ? (req.body.environmentId as string) - : null; - if (!environmentId) { - throw badRequest("A sandbox environment is required to start a device login."); - } - const ttlSeconds = - typeof req.body?.ttlSeconds === "number" && Number.isFinite(req.body.ttlSeconds) - ? (req.body.ttlSeconds as number) - : undefined; - - // Reject a non-sandbox or inactive environment before the service starts. - await assertSandboxLoginEnvironment(companyId, environmentId); + // The shared start-route spine derives the owner, checks the path adapter + // type, validates the strict request schema, and checks the sandbox + // environment before any session or lease side effect. The client body + // carries no adapter type, so the spine injects the path type into the + // parse. The strict schema rejects an unknown field, a non-uuid + // environment id, and an out-of-range time-to-live with a fixed 400. + const resolved = await runAdapterLoginStartSpine({ + req, + res, + deriveOwner: () => assertCanManageAdapterLogin(req, companyId), + guardBeforeValidate: () => assertStreamedExecLoginAdapter(type), + requestSchema: startAdapterAuthSessionRequestSchema, + invalidRequestError: "The device login start request is invalid.", + requestOverrides: { adapterType: type }, + assertSandbox: (data) => assertSandboxLoginEnvironment(companyId, data.environmentId), + }); + if (!resolved) return; + const { ownerUserId: startedByUserId, data } = resolved; const controller = new AbortController(); let result: Awaited>; try { result = await adapterLoginService.start({ companyId, - environmentId, + environmentId: data.environmentId, adapterType: type, startedByUserId, - ttlSeconds, + ttlSeconds: data.ttlSeconds, signal: controller.signal, }); } catch (error) { @@ -2556,7 +2567,7 @@ export function agentRoutes( const type = req.params.type as string; const sessionId = req.params.sessionId as string; const ownerUserId = await assertCanManageAdapterLogin(req, companyId); - assertCodexLoginAdapter(type); + assertStreamedExecLoginAdapter(type); const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId); if (!owner) { @@ -2576,7 +2587,7 @@ export function agentRoutes( const type = req.params.type as string; const sessionId = req.params.sessionId as string; const ownerUserId = await assertCanManageAdapterLogin(req, companyId); - assertCodexLoginAdapter(type); + assertStreamedExecLoginAdapter(type); // Scope the cancel to this company, adapter, and owner. A non-owner and a // cross-company caller both receive a 404 and cannot cancel a session. @@ -2589,7 +2600,7 @@ export function agentRoutes( // even when this process does not own the in-flight run, so a cross-process // cancel or a cancel after a restart does not leave the slot held until the // expiry. The reaper deletes the sandbox and finalizes the terminal. - const cancelled = await adapterLoginService.cancelOwnerSession(sessionId, ownerUserId); + const cancelled = await adapterLoginService.cancelOwnerSession(sessionId, companyId, ownerUserId); // Abort the in-flight run this process owns, so the local login stops at // once instead of waiting for the reaper. A run in another process, or an // already-terminal run, has no controller here. @@ -4631,66 +4642,85 @@ export function agentRoutes( router.post("/companies/:companyId/setup-token-login-sessions", async (req, res) => { const companyId = req.params.companyId as string; - assertCompanyAccess(req, companyId); - const ownerUserId = deriveSetupTokenOwnerUserId(req); - res.setHeader("Cache-Control", "no-store"); - // Parse the request with the shared strict validator before any session, - // lease, or pseudo-terminal side effect. `.strict()` rejects an unknown field, - // including a legacy `ttlSeconds`, with a fixed 400. The route echoes no input. - const parsed = startClaudeSetupTokenSessionRequestSchema.safeParse(req.body); - if (!parsed.success) { - res.status(400).json({ error: "The Claude login start request is invalid." }); - return; - } - const { environmentId, adapterType } = parsed.data; - const confirmedOverwrite: ClaudeSetupTokenOverwrite | null = parsed.data.overwrite ?? null; - - // The company-and-environment login serves only the Claude adapter. A - // different adapter type gets a fixed 400. - if (adapterType !== CLAUDE_SETUP_TOKEN_ADAPTER_TYPE) { - res.status(400).json({ error: "Only the claude_local adapter supports a setup-token login." }); - return; - } - - if (!SETUP_TOKEN_LOGIN_TRANSPORT_READY) { - // The live login transport binds at a later call site. Fail closed with the - // fixed no-secret error until then. - res.status(503).json({ error: SETUP_TOKEN_START_FAILED }); - return; - } - - // Resolve the environment server-side to a live instance sandbox. It fails - // closed on a missing, archived, non-sandbox, or fake-provider environment, so - // the route never persists an empty or invalid environment scope. It runs - // before the session starts, so no store holds a rejected environment. It - // also rejects an environment that another company owns (see - // `assertSandboxLoginEnvironment`), so a login never runs in a foreign - // company sandbox. It also fails closed when the provider does not advertise - // the setup-token login capability, so an unsupported provider never reaches - // a session row, a lease, or a pseudo-terminal. - await assertSandboxLoginEnvironment(companyId, environmentId, { - requireSetupTokenLoginProvider: true, + // The shared start-route spine derives the owner, validates the strict + // request schema, runs the Claude-only guards, and checks the sandbox + // environment before any session, lease, or pseudo-terminal side effect. + // + // The owner step runs the company access check, derives the owner, and then + // sets `Cache-Control: no-store`, so a rejected member sees no cache header + // and every other response carries it. The strict schema rejects an unknown + // field, including a legacy `ttlSeconds`, with a fixed 400. The post-validate + // guard rejects a non-Claude adapter with a fixed 400 and fails closed with + // the fixed no-secret 503 until the live login transport binds. The sandbox + // check fails closed on a missing, archived, non-sandbox, fake-provider, or + // foreign environment, and on a provider without the setup-token login + // capability, so no rejected environment reaches a session row, a lease, or a + // pseudo-terminal. + const resolved = await runAdapterLoginStartSpine({ + req, + res, + deriveOwner: () => { + assertCompanyAccess(req, companyId); + const ownerUserId = deriveSetupTokenOwnerUserId(req); + res.setHeader("Cache-Control", "no-store"); + return ownerUserId; + }, + requestSchema: startClaudeSetupTokenSessionRequestSchema, + invalidRequestError: "The Claude login start request is invalid.", + guardAfterValidate: (data) => { + // The setup-token route drives a login on a pseudo-terminal and records a + // stored session identifier on success. It serves any adapter whose + // registry login capability declares that transport and that claim. The + // guard reads the capability, not the adapter name, so a new adapter with + // the same capability passes with no code change. It rejects an adapter + // with no matching capability with a fixed 400. + const capability = getRegistryLoginCapability(data.adapterType); + if ( + capability?.sandboxTransport !== "pseudo_terminal" || + capability.completionClaim !== "storedSessionId" + ) { + res.status(400).json({ error: "This adapter does not support a setup-token login." }); + return true; + } + if (!SETUP_TOKEN_LOGIN_TRANSPORT_READY) { + res.status(503).json({ error: SETUP_TOKEN_START_FAILED }); + return true; + } + return false; + }, + assertSandbox: (data) => + assertSandboxLoginEnvironment(companyId, data.environmentId, { + requireSetupTokenLoginProvider: true, + }), }); + if (!resolved) return; + const { ownerUserId, data } = resolved; + const { environmentId, adapterType } = data; + const confirmedOverwrite: ClaudeSetupTokenOverwrite | null = data.overwrite ?? null; const scope: SetupTokenSessionScope = { companyId, ownerUserId, - targetAgentId: null, adapterType, environmentId, confirmedOverwrite, }; + // Read the panel mode from the adapter capability. The guard already checked + // the capability, so it is present here. The client renders the panel from + // this value instead of a hard-coded mode. + const panelMode = + getRegistryLoginCapability(adapterType)?.panelMode ?? "submitted_browser_code"; try { const started = await setupTokenLoginService.start(scope); const descriptor = setupTokenLoginService.describeOwned(started.sessionId, scope); // The start response carries the panel mode, so the client renders the - // browser-code panel. The full login URL rides only through the - // guarded prompt read, not the start response, so the prompt is null here. - // The client reads the prompt route for the login URL. + // correct panel. The full login URL rides only through the guarded prompt + // read, not the start response, so the prompt is null here. The client + // reads the prompt route for the login URL. const body: ClaudeSetupTokenSessionOwnerResponse = { ...toClaudePublicResponse(descriptor), - panelMode: "submitted_browser_code", + panelMode, prompt: null, }; res.status(201).json(body); diff --git a/server/src/routes/environments.ts b/server/src/routes/environments.ts index 38fe085738f..053d11f1154 100644 --- a/server/src/routes/environments.ts +++ b/server/src/routes/environments.ts @@ -742,7 +742,7 @@ export function environmentRoutes( templateRefKind: driver.templateRefKind, templateConfigBinding: driver.templateConfigBinding, supportsTemplateDelete: driver.supportsTemplateDelete, - supportsSetupTokenLogin: driver.supportsSetupTokenLogin ?? false, + supportsLoginPty: driver.supportsLoginPty ?? false, displayName: driver.displayName, description: driver.description, source: "plugin" as const, diff --git a/server/src/routes/setup-token-route.test.ts b/server/src/routes/setup-token-route.test.ts index 4b0ca17b9a8..269a78d057b 100644 --- a/server/src/routes/setup-token-route.test.ts +++ b/server/src/routes/setup-token-route.test.ts @@ -105,6 +105,22 @@ const mockRunSecretRedactionRegistry = vi.hoisted(() => ({ const mockSecretService = vi.hoisted(() => ({ readClaudeOAuthUserSecretStatus: vi.fn(), })); +// The registry lookup the start-route guard reads. A `beforeEach` sets the +// default so `claude_local` declares the setup-token capability. A test overrides +// it to prove the guard reads the capability, not the adapter name. +const mockFindActiveServerAdapter = vi.hoisted(() => vi.fn()); + +// The Claude setup-token login capability the registry declares for the built-in +// adapter. The guard requires the pseudo-terminal transport and the stored-session +// completion claim. +const CLAUDE_LOGIN_CAPABILITY = { + panelMode: "submitted_browser_code", + sandboxTransport: "pseudo_terminal", + timeoutPolicy: "fixed", + completionClaim: "storedSessionId", + getCommand: () => "", + parsePrompt: () => null, +} as const; function registerModuleMocks(): void { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); @@ -160,11 +176,16 @@ function registerModuleMocks(): void { syncInstructionsBundleConfigFromFilePath: vi.fn((_agent: unknown, config: unknown) => config), workspaceOperationService: () => ({}), })); + // The start-route guard reads the login capability from the registry. The + // `beforeEach` default declares the Claude setup-token capability for + // `claude_local` and no capability for any other type, so the guard passes for + // `claude_local` and fails closed for a different adapter through capability + // data alone. vi.doMock("../adapters/index.js", () => ({ findServerAdapter: vi.fn(), listAdapterModels: vi.fn(), detectAdapterModel: vi.fn(), - findActiveServerAdapter: vi.fn(), + findActiveServerAdapter: mockFindActiveServerAdapter, requireServerAdapter: vi.fn(), })); } @@ -430,6 +451,12 @@ beforeEach(() => { registerModuleMocks(); vi.clearAllMocks(); useOwner(); + // The default registry declares the Claude setup-token capability for + // `claude_local` and no capability for any other type. A test overrides this to + // prove the guard reads the capability, not the adapter name. + mockFindActiveServerAdapter.mockImplementation((type: string) => + type === "claude_local" ? { type, loginCapability: CLAUDE_LOGIN_CAPABILITY } : undefined, + ); // The default owner has no stored Claude value. A test overrides this to prove // the status route returns the metadata for a present owner value. mockSecretService.readClaudeOAuthUserSecretStatus.mockResolvedValue(null); @@ -459,7 +486,7 @@ beforeEach(() => { mockResolvePluginSandboxProviderDriverByKey.mockImplementation( async ({ driverKey }: { driverKey: string }) => driverKey === "daytona" - ? { plugin: { id: "plugin-daytona" }, driver: { supportsSetupTokenLogin: true } } + ? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: true } } : null, ); }); @@ -651,7 +678,10 @@ describe("company-and-environment setup-token route — object-level authorizati expect(transport.submittedCodes).toEqual([]); }); - it("rejects an adapter other than claude_local at start", async () => { + it("rejects an adapter whose login capability does not match the setup-token guard", async () => { + // The Codex adapter declares a streamed-exec device login, not a + // pseudo-terminal setup-token login. The guard reads the capability, so it + // rejects the adapter with a fixed 400 before any session. const transport = buildTransport({ onSubmit: "pending" }); const { app } = await createApp({ transport }); @@ -664,6 +694,32 @@ describe("company-and-environment setup-token route — object-level authorizati // holds no record. expect(transport.records).toEqual([]); }); + + it("starts a setup-token login for a third adapter that declares the capability", async () => { + // A third adapter, not the Claude adapter, declares the pseudo-terminal + // setup-token capability with the stored-session claim. The guard reads the + // capability, not the adapter name, so the adapter passes the guard and + // starts a session. This proves no adapter-name branch remains in the guard + // path. The start response reads the panel mode from the capability. + mockFindActiveServerAdapter.mockImplementation((type: string) => + type === "gemini_local" + ? { + type, + loginCapability: { ...CLAUDE_LOGIN_CAPABILITY, panelMode: "displayed_code" }, + } + : undefined, + ); + const transport = buildTransport({ onSubmit: "pending" }); + const { app } = await createApp({ transport }); + + const res = await startCompanySession(app, { + environmentId: ENVIRONMENT_ID, + adapterType: "gemini_local", + }); + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.panelMode).toBe("displayed_code"); + expect(res.body.status).toBe("waiting_for_user"); + }); }); describe("company-and-environment setup-token route — idempotent cancel", () => { diff --git a/server/src/services/adapter-login-lease.test.ts b/server/src/services/adapter-login-lease.test.ts new file mode 100644 index 00000000000..5a0d6984aa2 --- /dev/null +++ b/server/src/services/adapter-login-lease.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { Environment } from "@paperclipai/shared"; +import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js"; + +// A minimal environment stand-in. The helper copies the reference without a +// read, so the test does not need a full environment row. +const ENVIRONMENT = { id: "env-1", name: "Sandbox", driver: "sandbox" } as unknown as Environment; + +describe("buildLoginLeaseAcquireArgs", () => { + it("sets the fixed lease arguments that both login services share", () => { + const args = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT, adapterType: "codex" }, + }); + + // A null issue, a null heartbeat run, and a null execution workspace disable + // lease reuse, so the login session always runs in a fresh sandbox. + expect(args.issueId).toBeNull(); + expect(args.heartbeatRunId).toBeNull(); + expect(args.persistedExecutionWorkspace).toBeNull(); + // The helper applies the active custom-image template for both services. + expect(args.applyCustomImageTemplate).toBe(true); + }); + + it("passes the lease metadata through to the acquire arguments", () => { + const args = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT, adapterType: "claude" }, + }); + + expect(args.companyId).toBe("co-1"); + expect(args.environment).toBe(ENVIRONMENT); + expect(args.adapterType).toBe("claude"); + }); + + it("defaults the adapter type to null when the caller omits it", () => { + const args = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT }, + }); + + expect(args.adapterType).toBeNull(); + }); + + it("passes the target agent through to the lease agent and defaults to null", () => { + const withAgent = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT }, + targetAgentId: "agent-7", + }); + expect(withAgent.agentId).toBe("agent-7"); + + const withoutAgent = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT }, + }); + expect(withoutAgent.agentId).toBeNull(); + }); + + it("passes the company-binding assertion through and leaves it unset by default", () => { + const asserted = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT }, + assertCompanyBinding: true, + }); + expect(asserted.assertCompanyBinding).toBe(true); + + const unset = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT }, + }); + expect(unset.assertCompanyBinding).toBeUndefined(); + }); + + it("passes the requested expiry through and defaults to null", () => { + const deadline = new Date("2026-01-01T00:00:00.000Z"); + const bounded = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT }, + requestedExpiresAt: deadline, + }); + expect(bounded.requestedExpiresAt).toBe(deadline); + + const unbounded = buildLoginLeaseAcquireArgs({ + metadata: { companyId: "co-1", environment: ENVIRONMENT }, + }); + expect(unbounded.requestedExpiresAt).toBeNull(); + }); +}); diff --git a/server/src/services/adapter-login-lease.ts b/server/src/services/adapter-login-lease.ts new file mode 100644 index 00000000000..2a573abe0be --- /dev/null +++ b/server/src/services/adapter-login-lease.ts @@ -0,0 +1,72 @@ +import type { Environment } from "@paperclipai/shared"; +import type { EnvironmentRuntimeService } from "./environment-runtime.js"; + +/** + * The argument object that the runtime `acquireRunLease` seam accepts. The type + * derives from the service method, so a change to the acquire input stays in + * sync with this helper. + */ +export type AcquireLoginLeaseArgs = Parameters[0]; + +/** + * The lease metadata that identifies the login sandbox. The helper copies these + * fields to the acquire arguments without a change. + */ +export interface LoginLeaseMetadata { + companyId: string; + environment: Environment; + /** The agent adapter type for this login (mixed-harness environments). */ + adapterType?: string | null; +} + +/** + * The options for one login lease acquire. The helper sets the fixed arguments + * and passes each option through to the acquire arguments. + */ +export interface BuildLoginLeaseAcquireArgsOptions { + /** The lease metadata that identifies the login sandbox. */ + metadata: LoginLeaseMetadata; + /** + * The agent that owns the login. The codex flow passes null. The setup-token + * flow passes the target agent. + */ + targetAgentId?: string | null; + /** + * Re-check the environment company binding inside the lease insert + * transaction. The setup-token flow sets this to true. + */ + assertCompanyBinding?: boolean; + /** + * The latest time the acquired lease may stay active. The setup-token flow + * passes the session deadline. The codex flow passes nothing. + */ + requestedExpiresAt?: Date | null; +} + +/** + * Build the fixed sandbox lease arguments for a login service. Both login + * services acquire a lease with a null issue, a null heartbeat run, and a null + * execution workspace, and both apply the active custom-image template. This + * helper sets those fixed arguments in one place and passes the caller options + * through without a change to the lease behavior. + */ +export function buildLoginLeaseAcquireArgs( + options: BuildLoginLeaseAcquireArgsOptions, +): AcquireLoginLeaseArgs { + return { + companyId: options.metadata.companyId, + environment: options.metadata.environment, + adapterType: options.metadata.adapterType ?? null, + agentId: options.targetAgentId ?? null, + // A null issue, a null heartbeat run, and a null execution workspace disable + // lease reuse, so the login session always runs in a fresh sandbox. + issueId: null, + heartbeatRunId: null, + persistedExecutionWorkspace: null, + // Apply the active custom-image template, so the sandbox binds to the + // trusted image and runtime identity. + applyCustomImageTemplate: true, + assertCompanyBinding: options.assertCompanyBinding, + requestedExpiresAt: options.requestedExpiresAt ?? null, + }; +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 9c0500ba0b9..d4f6a129637 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -586,7 +586,6 @@ export function agentService(db: Db) { companyId: input.companyId, ownerUserId: ownerUserId ?? "", adapterType: CLAUDE_LOCAL_ADAPTER_TYPE, - environmentId: input.environmentId ?? "", }); if (!consumed) { throw claudeOAuthClaimRejectedError(); diff --git a/server/src/services/codex-device-login-reaper.ts b/server/src/services/codex-device-login-reaper.ts index a2300636982..8096b0476cf 100644 --- a/server/src/services/codex-device-login-reaper.ts +++ b/server/src/services/codex-device-login-reaper.ts @@ -1,8 +1,9 @@ -import { inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { adapterAuthSessions } from "@paperclipai/db"; import { ADAPTER_AUTH_ACTIVE_STATUSES, + CODEX_DEVICE_LOGIN_ADAPTER_TYPE, decodePendingTerminal, LOGIN_LEASE_SESSION_TAG_KEY, observeSandboxDelete, @@ -166,6 +167,7 @@ export function createCodexDeviceLoginReaper(deps: CodexDeviceLoginReaperDeps) { const pendingWrite = terminalCleanupWrite(false, "timed_out", null); const claimed = await store.withCompanyAdapterPromotionLock( row.companyId, + row.startedByUserId, row.adapterType, () => store.compareAndSetStatus({ @@ -313,10 +315,15 @@ export function createProductionLoginSessionReaperRuntime( .selectDistinct({ environmentId: adapterAuthSessions.environmentId }) .from(adapterAuthSessions) .where( - inArray(adapterAuthSessions.status, [ - ...ADAPTER_AUTH_ACTIVE_STATUSES, - "cleanup_pending", - ]), + and( + // The shared table also holds the setup-token rows. Filter by the + // device-login adapter, so the orphan sweep reads only Codex rows. + eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE), + inArray(adapterAuthSessions.status, [ + ...ADAPTER_AUTH_ACTIVE_STATUSES, + "cleanup_pending", + ]), + ), ); const tagged: TaggedLease[] = []; for (const { environmentId } of environmentRows) { diff --git a/server/src/services/codex-device-login-service.ts b/server/src/services/codex-device-login-service.ts index f2774b9da94..a3c0d4361fa 100644 --- a/server/src/services/codex-device-login-service.ts +++ b/server/src/services/codex-device-login-service.ts @@ -21,6 +21,7 @@ import { type SandboxLoginDriver, } from "@paperclipai/adapter-codex-local/server"; import type { EnvironmentRuntimeService } from "./environment-runtime.js"; +import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js"; import { environmentService } from "./environments.js"; // The login-session service. It creates a login session, acquires a fresh @@ -104,11 +105,12 @@ export interface LoginSessionRuntime { } /** The per-session context the promotion seam needs. The service knows the - * session, the company, and the adapter, so the promotion resolves the company - * scope and the sole-active-owner check for this exact session. */ + * session, the company, the owner, and the adapter, so the promotion resolves + * the company slot and the sole-active-owner check for this exact session. */ export interface CredentialPromotionContext { sessionId: string; companyId: string; + startedByUserId: string; adapterType: AgentAdapterType; } @@ -210,6 +212,10 @@ export class AdapterAuthSessionConflictError extends Error { export interface AdapterAuthSessionRow { id: string; + /** The public, CSPRNG session identifier. The API returns and looks up this + * value. It never equals the internal primary-key `id`, so a caller cannot + * address a row by the internal id. */ + publicSessionId: string; companyId: string; environmentId: string; adapterType: AgentAdapterType; @@ -227,6 +233,9 @@ export interface AdapterAuthSessionRow { export interface InsertAdapterAuthSessionInput { id: string; + /** The public, CSPRNG session identifier. The service builds it and returns it + * to the client; the store persists it in `public_session_id`. */ + publicSessionId: string; companyId: string; environmentId: string; adapterType: AgentAdapterType; @@ -272,33 +281,44 @@ export interface AdapterAuthSessionStore { /** A conditional status write. It returns true only when it changed one row. */ compareAndSetStatus(input: CompareAndSetAdapterAuthSessionStatusInput): Promise; get(sessionId: string): Promise; + /** + * Read a session by its public session id, scoped to the company and the Codex + * device-login adapter. The predicate carries the company id, so a query never + * keys on the public session id alone, and a foreign-company caller reads + * nothing. It never accepts the internal primary-key `id`. + */ + getByPublicId(publicSessionId: string, companyId: string): Promise; /** Run `fn` while the process holds the promotion critical-section lock for the - * company and adapter. The reaper reclaims a stale `promoting` row inside this - * lock, so a reclaim never interleaves with a live credential write. */ + * company, owner, and adapter slot. The reaper reclaims a stale `promoting` + * row inside this lock, so a reclaim never interleaves with a live credential + * write for the same slot. */ withCompanyAdapterPromotionLock( companyId: string, + startedByUserId: string, adapterType: AgentAdapterType, fn: () => Promise, ): Promise; } /** Build the advisory-lock key for the promotion critical section. The key is - * company-scoped and adapter-scoped, so two different company slots never - * contend. The credential-promotion path and the reaper reclaim both derive the - * key from this function, so they take the exact same lock. */ + * scoped to the company, the owner, and the adapter, so two different slots + * never contend. The credential-promotion path and the reaper reclaim both + * derive the key from this function, so they take the exact same lock. */ export function adapterLoginPromotionLockKey( companyId: string, + startedByUserId: string, adapterType: AgentAdapterType, ): string { - return `paperclip:adapter-login-promotion:${companyId}:${adapterType}`; + return `paperclip:adapter-login-promotion:${companyId}:${startedByUserId}:${adapterType}`; } /** - * Run `fn` inside the promotion critical section for one company and adapter. + * Run `fn` inside the promotion critical section for one company, owner, and + * adapter slot. * * The function opens a database transaction and takes a transaction-scoped * PostgreSQL advisory lock. The lock serializes the credential-promotion path - * against the reaper reclaim, so a reaper never releases the company slot while a + * against the reaper reclaim, so a reaper never releases the slot while a * credential write runs, and a stale promotion never writes after the reaper * reclaims the slot. The transaction holds the lock through `fn`, so the caller * runs the ownership check and the credential write in one mutually-exclusive @@ -308,10 +328,11 @@ export function adapterLoginPromotionLockKey( export async function withAdapterLoginPromotionLock( db: Db, companyId: string, + startedByUserId: string, adapterType: AgentAdapterType, fn: () => Promise, ): Promise { - const key = adapterLoginPromotionLockKey(companyId, adapterType); + const key = adapterLoginPromotionLockKey(companyId, startedByUserId, adapterType); return db.transaction(async (tx) => { await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`); return fn(); @@ -354,10 +375,12 @@ export interface AdapterAuthReaperStore { compareAndSetStatus(input: CompareAndSetAdapterAuthSessionStatusInput): Promise; get(sessionId: string): Promise; /** Run `fn` while the process holds the promotion critical-section lock for the - * company and adapter. The reaper reclaims a stale `promoting` row inside this - * lock, so a reclaim never interleaves with a live credential write. */ + * company, owner, and adapter slot. The reaper reclaims a stale `promoting` + * row inside this lock, so a reclaim never interleaves with a live credential + * write for the same slot. */ withCompanyAdapterPromotionLock( companyId: string, + startedByUserId: string, adapterType: AgentAdapterType, fn: () => Promise, ): Promise; @@ -385,12 +408,16 @@ function isUniqueViolation(error: unknown): boolean { function toRow(row: typeof adapterAuthSessions.$inferSelect): AdapterAuthSessionRow { return { id: row.id, + publicSessionId: row.publicSessionId, companyId: row.companyId, environmentId: row.environmentId, adapterType: row.adapterType, startedByUserId: row.startedByUserId, providerLeaseId: row.providerLeaseId ?? null, - status: row.status, + // The unified `status` column type is the merged login-state union. A Codex + // device-login row only ever holds a Codex internal status, so narrow the + // read back to the internal union the service reasons about. + status: row.status as AdapterAuthSessionInternalStatus, expiresAt: row.expiresAt ?? null, promotionExpiresAt: row.promotionExpiresAt ?? null, finishedAt: row.finishedAt ?? null, @@ -427,6 +454,11 @@ export function createDbAdapterAuthSessionStore( environmentId: input.environmentId, adapterType: input.adapterType, startedByUserId: input.startedByUserId, + // The unified table requires a unique public session id. The service + // builds it from a CSPRNG and returns it to the client, so the store + // persists that value here. It never uses the internal id, a timestamp, + // or a counter. + publicSessionId: input.publicSessionId, status: "starting", expiresAt: input.expiresAt, createdAt: input.at, @@ -477,6 +509,25 @@ export function createDbAdapterAuthSessionStore( const row = rows[0]; return row ? toRow(row) : null; }, + async getByPublicId(publicSessionId, companyId) { + // The predicate carries the company id and the Codex device-login adapter, + // so a read never keys on the public session id alone. A foreign-company or + // foreign-adapter caller reads nothing. The internal primary-key `id` never + // matches, so a caller cannot address a row by the internal id. + const rows = await db + .select() + .from(adapterAuthSessions) + .where( + and( + eq(adapterAuthSessions.publicSessionId, publicSessionId), + eq(adapterAuthSessions.companyId, companyId), + eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE), + ), + ) + .limit(1); + const row = rows[0]; + return row ? toRow(row) : null; + }, async listExpiredActiveSessions(nowAt) { // The partial index on the active statuses and the index on `expiresAt` // both support this scan. The scan is bounded by the active-status set, so @@ -489,6 +540,9 @@ export function createDbAdapterAuthSessionStore( .from(adapterAuthSessions) .where( and( + // The shared table also holds the setup-token rows, so every reaper + // scan filters by the device-login adapter to reach only Codex rows. + eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE), inArray(adapterAuthSessions.status, [...ADAPTER_AUTH_ACTIVE_STATUSES]), isNotNull(adapterAuthSessions.expiresAt), lte(adapterAuthSessions.expiresAt, nowAt), @@ -505,20 +559,30 @@ export function createDbAdapterAuthSessionStore( const rows = await db .select() .from(adapterAuthSessions) - .where(eq(adapterAuthSessions.status, "cleanup_pending")); + .where( + and( + eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE), + eq(adapterAuthSessions.status, "cleanup_pending"), + ), + ); return rows.map(toRow); }, async listLeaseReferences() { const rows = await db .select({ providerLeaseId: adapterAuthSessions.providerLeaseId }) .from(adapterAuthSessions) - .where(isNotNull(adapterAuthSessions.providerLeaseId)); + .where( + and( + eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE), + isNotNull(adapterAuthSessions.providerLeaseId), + ), + ); return rows .map((row) => row.providerLeaseId) .filter((value): value is string => value != null); }, - async withCompanyAdapterPromotionLock(companyId, adapterType, fn) { - return withAdapterLoginPromotionLock(db, companyId, adapterType, fn); + async withCompanyAdapterPromotionLock(companyId, startedByUserId, adapterType, fn) { + return withAdapterLoginPromotionLock(db, companyId, startedByUserId, adapterType, fn); }, }; } @@ -644,6 +708,10 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) input: StartCodexDeviceLoginInput, ): Promise { const sessionId = randomUUID(); + // The public session identifier the API returns and looks up. It is an + // independent CSPRNG value, so it never equals the internal `sessionId` and a + // caller cannot address the row by the internal id. + const publicSessionId = randomUUID(); const startedAt = now(); const ttlSeconds = input.ttlSeconds ?? CODEX_DEVICE_LOGIN_TIMEOUT_MS / 1000; const expiresAt = new Date(startedAt.getTime() + ttlSeconds * 1000); @@ -662,6 +730,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) // acquires a lease for a losing start. await store.insert({ id: sessionId, + publicSessionId, companyId: input.companyId, environmentId: input.environmentId, adapterType: input.adapterType, @@ -726,7 +795,8 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) activity("lease_acquired"); const session: AdapterAuthSessionResponse = { - sessionId, + // The API identifier is the public session id, never the internal `sessionId`. + sessionId: publicSessionId, environmentId: input.environmentId, status: "starting", expiresAt: expiresAt.toISOString(), @@ -837,6 +907,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) await promotion.promote(credential, { sessionId, companyId: input.companyId, + startedByUserId: input.startedByUserId, adapterType: input.adapterType, }); } catch { @@ -972,24 +1043,28 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) } async function readOwnerSession( - sessionId: string, + publicSessionId: string, + companyId: string, requestingUserId: string, ): Promise { - const row = await store.get(sessionId); + // Look the row up by its public session id, scoped to the company. A + // foreign-company caller reads nothing, and the internal id never matches. + const row = await store.getByPublicId(publicSessionId, companyId); if (!row) return null; const isOwner = row.startedByUserId === requestingUserId; const status = resolvePublicStatus(row); // Deliver the one-time prompt to the owner principal exactly once. The read // and the delete run with no await between them, so the first authorized // owner read consumes the prompt and every later read returns null. This - // keeps the short-lived device code out of a repeated response. + // keeps the short-lived device code out of a repeated response. The prompt + // map keys on the internal id, so read it by `row.id`, not the public id. let prompt: DeviceLoginPrompt | null = null; if (isOwner) { - prompt = promptsBySession.get(sessionId) ?? null; - if (prompt) promptsBySession.delete(sessionId); + prompt = promptsBySession.get(row.id) ?? null; + if (prompt) promptsBySession.delete(row.id); } return { - sessionId: row.id, + sessionId: row.publicSessionId, environmentId: row.environmentId, status, expiresAt: row.expiresAt?.toISOString() ?? null, @@ -1011,14 +1086,18 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) // row, so a cancel never interrupts an in-flight credential write; that // promotion runs to its own terminal. async function cancelOwnerSession( - sessionId: string, + publicSessionId: string, + companyId: string, requestingUserId: string, ): Promise { - const row = await store.get(sessionId); + // Look the row up by its public session id, scoped to the company. The status + // write keys on the internal `row.id`, so a cancel never addresses a row by + // the public id alone. + const row = await store.getByPublicId(publicSessionId, companyId); if (!row || row.startedByUserId !== requestingUserId) return null; const write = terminalCleanupWrite(false, "cancelled", null); await store.compareAndSetStatus({ - sessionId, + sessionId: row.id, expectedStatuses: ["starting", "waiting_for_user"], status: write.status, at: now(), @@ -1026,7 +1105,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps) finishedAt: now(), promotionExpiresAt: null, }); - return readOwnerSession(sessionId, requestingUserId); + return readOwnerSession(publicSessionId, companyId, requestingUserId); } return { start, readOwnerSession, cancelOwnerSession }; @@ -1157,20 +1236,15 @@ export function createProductionLoginSessionRuntime( if (!environment) { throw new Error(`Environment "${input.environmentId}" is not found.`); } - const record = await deps.environmentRuntime.acquireRunLease({ - companyId: input.companyId, - environment, - issueId: null, - agentId: null, - // A null heartbeat run and a null execution workspace disable lease - // reuse, so the login session always runs in a fresh sandbox. - heartbeatRunId: null, - persistedExecutionWorkspace: null, - adapterType: input.adapterType, - // Apply the active custom-image template, so the sandbox binds to the - // trusted image and runtime identity. - applyCustomImageTemplate: true, - }); + const record = await deps.environmentRuntime.acquireRunLease( + buildLoginLeaseAcquireArgs({ + metadata: { + companyId: input.companyId, + environment, + adapterType: input.adapterType, + }, + }), + ); // Tag the lease with the session identifier, so the reaper resolves an // orphan lease that no live session references. The tag carries no secret. await environmentsSvc.updateLeaseMetadata(record.lease.id, { diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index 3ad4d7ef21b..6ef33f982fb 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -78,7 +78,7 @@ export interface ReadyPluginEnvironmentDriver { templateRefKind?: PluginEnvironmentDriverDeclaration["templateRefKind"]; templateConfigBinding?: PluginEnvironmentDriverDeclaration["templateConfigBinding"]; supportsTemplateDelete?: PluginEnvironmentDriverDeclaration["supportsTemplateDelete"]; - supportsSetupTokenLogin?: PluginEnvironmentDriverDeclaration["supportsSetupTokenLogin"]; + supportsLoginPty?: PluginEnvironmentDriverDeclaration["supportsLoginPty"]; } export function pluginDriverProviderKey(config: Pick): string { @@ -257,7 +257,7 @@ export async function listReadyPluginEnvironmentDrivers(input: { templateRefKind: driver.templateRefKind, templateConfigBinding: driver.templateConfigBinding, supportsTemplateDelete: driver.supportsTemplateDelete, - supportsSetupTokenLogin: driver.supportsSetupTokenLogin, + supportsLoginPty: driver.supportsLoginPty, })), ); } diff --git a/server/src/services/setup-token-reaper.test.ts b/server/src/services/setup-token-reaper.test.ts new file mode 100644 index 00000000000..e74ee4acd3c --- /dev/null +++ b/server/src/services/setup-token-reaper.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { createSetupTokenReaper } from "./setup-token-reaper.js"; +import { + isTerminalSessionState, + type SetupTokenCleanupIdentity, + type SetupTokenCleanupRecord, +} from "./setup-token-session.js"; + +const NOW = 5_000; + +// An in-memory reaper store. The test seeds records directly, so no database +// runs. The `listReapable` predicate mirrors the production store: a record is +// reapable when its session is terminal, its deadline is past, or its claim is +// already consumed. +function createMemoryStore() { + const rows = new Map(); + const matches = (row: SetupTokenCleanupRecord, identity: SetupTokenCleanupIdentity) => + row.companyId === identity.companyId && + row.ownerUserId === identity.ownerUserId && + row.adapterType === identity.adapterType; + return { + rows, + seed(record: Partial & Pick) { + const row: SetupTokenCleanupRecord = { + sessionId: record.sessionId, + companyId: record.companyId ?? "company-1", + ownerUserId: record.ownerUserId ?? "user-1", + adapterType: record.adapterType ?? "claude_local", + environmentId: record.environmentId ?? "env-1", + leaseId: record.leaseId, + deadline: record.deadline ?? 1_000, + state: record.state ?? "failed", + boundAt: record.boundAt ?? null, + }; + rows.set(row.sessionId, row); + return row; + }, + async listReapable(now: number): Promise { + return [...rows.values()].filter( + (row) => isTerminalSessionState(row.state) || row.deadline <= now || row.boundAt !== null, + ); + }, + async remove(identity: SetupTokenCleanupIdentity): Promise { + const row = rows.get(identity.sessionId); + if (row && matches(row, identity)) rows.delete(identity.sessionId); + }, + }; +} + +interface FakeLeasesOptions { + releaseImpl?: (leaseId: string) => Promise; +} + +function createFakeLeases(opts: FakeLeasesOptions = {}) { + const releaseByIdCalls: string[] = []; + return { + releaseByIdCalls, + async releaseById(leaseId: string): Promise { + releaseByIdCalls.push(leaseId); + if (opts.releaseImpl) await opts.releaseImpl(leaseId); + }, + }; +} + +describe("setup-token reaper", () => { + it("releases the lease and removes the record for a reapable session", async () => { + // A prior process crashed with a live record and a lease. + const store = createMemoryStore(); + store.seed({ sessionId: "orphan-1", leaseId: "lease-orphan", state: "awaiting_code", deadline: 1_000 }); + const leases = createFakeLeases(); + const reaper = createSetupTokenReaper({ store, leases, now: () => NOW }); + + const result = await reaper.sweep(); + + expect(result.released).toBe(1); + expect(result.failed).toBe(0); + expect(leases.releaseByIdCalls).toEqual(["lease-orphan"]); + expect(store.rows.size).toBe(0); + }); + + it("holds a live, unexpired, unconsumed record and never releases its lease", async () => { + const store = createMemoryStore(); + store.seed({ + sessionId: "live-1", + leaseId: "lease-live", + state: "awaiting_code", + // The deadline is in the future and the claim is not consumed. + deadline: NOW + 60_000, + boundAt: null, + }); + const leases = createFakeLeases(); + const reaper = createSetupTokenReaper({ store, leases, now: () => NOW }); + + const result = await reaper.sweep(); + + expect(result.released).toBe(0); + expect(leases.releaseByIdCalls).toHaveLength(0); + expect(store.rows.has("live-1")).toBe(true); + }); + + it("keeps the record when the release fails, then clears it on the next sweep", async () => { + const store = createMemoryStore(); + store.seed({ sessionId: "orphan-2", leaseId: "lease-orphan-2", state: "failed" }); + let attempt = 0; + const leases = createFakeLeases({ + releaseImpl: async () => { + attempt += 1; + if (attempt === 1) throw new Error("provider down"); + }, + }); + const reaper = createSetupTokenReaper({ store, leases, now: () => NOW }); + + // First sweep: the release rejects, so the record stays for a retry. + const first = await reaper.sweep(); + expect(first.released).toBe(0); + expect(first.failed).toBe(1); + expect(store.rows.has("orphan-2")).toBe(true); + + // Second sweep: the release confirms, so the record clears. + const second = await reaper.sweep(); + expect(second.released).toBe(1); + expect(second.failed).toBe(0); + expect(leases.releaseByIdCalls).toEqual(["lease-orphan-2", "lease-orphan-2"]); + expect(store.rows.size).toBe(0); + }); + + it("reaps every reapable record in one sweep and stays idempotent on the next", async () => { + const store = createMemoryStore(); + store.seed({ sessionId: "orphan-a", leaseId: "lease-a", state: "failed" }); + store.seed({ sessionId: "orphan-b", leaseId: "lease-b", state: "cancelled" }); + // A consumed stored claim is reapable through the bound marker. + store.seed({ sessionId: "orphan-c", leaseId: "lease-c", state: "stored", deadline: NOW + 60_000, boundAt: 4_000 }); + const leases = createFakeLeases(); + const reaper = createSetupTokenReaper({ store, leases, now: () => NOW }); + + const first = await reaper.sweep(); + expect(first.released).toBe(3); + expect(store.rows.size).toBe(0); + + // The interval sweep runs over the now-empty store without an error. + const second = await reaper.sweep(); + expect(second.released).toBe(0); + expect(second.failed).toBe(0); + }); +}); diff --git a/server/src/services/setup-token-reaper.ts b/server/src/services/setup-token-reaper.ts new file mode 100644 index 00000000000..0a6926bcb3b --- /dev/null +++ b/server/src/services/setup-token-reaper.ts @@ -0,0 +1,87 @@ +import type { Db } from "@paperclipai/db"; +import type { EnvironmentRuntimeService } from "./environment-runtime.js"; +import { environmentService } from "./environments.js"; +import { + reapSetupTokenLeases, + type SetupTokenCleanupStore, + type SetupTokenLeaseManager, + type SetupTokenReapResult, +} from "./setup-token-session.js"; +import { + createProductionSetupTokenCleanupStore, + createProductionSetupTokenSandboxProvider, +} from "./setup-token-transport-binding.js"; + +// The restart-safe cleanup backstop for the Claude setup-token login flow. +// +// The in-process five-minute timer in the login-session service stays the +// primary control. This reaper is the backstop. It runs on server startup and on +// a fixed interval, so a sandbox lease survives a server restart and a release +// failure. The reaper reads the durable cleanup store and releases any lease +// whose session is terminal, past its deadline, or already consumed. It uses the +// same convention as the Codex device-login reaper, so both flows use one reaper +// convention. +// +// The reaper never records secret data. The durable cleanup record holds only +// ids, the deadline, the claim marker, and the state. It never holds a URL, a +// code, a token, or a raw process chunk. + +/** The dependencies the standalone reaper needs. The reaper only reads reapable + * records and releases a lease by id, so it takes a store subset and a lease + * releaser, not the full session service. */ +export interface SetupTokenReaperDeps { + store: Pick; + leases: Pick; + now?: () => number; + log?: (line: string) => void; +} + +/** + * Builds the standalone setup-token reaper. One sweep reaps every reapable + * record: it releases the lease and clears the row. A failed release stays + * retryable, so the next sweep retries it. + */ +export function createSetupTokenReaper(deps: SetupTokenReaperDeps) { + const now = deps.now ?? Date.now; + async function sweep(): Promise { + return reapSetupTokenLeases({ store: deps.store, leases: deps.leases, log: deps.log }, now()); + } + return { sweep }; +} + +export type SetupTokenReaper = ReturnType; + +// --------------------------------------------------------------------------- +// The production runtime binding. +// --------------------------------------------------------------------------- + +export interface ProductionSetupTokenReaperDeps { + db: Db; + environmentRuntime: EnvironmentRuntimeService; + log?: (line: string) => void; +} + +/** + * Builds the production setup-token reaper. It reads the durable cleanup store + * and releases a lease by id through the production sandbox provider. The + * provider release resolves the lease and its environment from the database and + * tears down the remote sandbox through the driver, so the release frees the + * remote sandbox and not only the database row. The reaper never acquires a + * lease, so it binds no live pseudo-terminal opener. + */ +export function createProductionSetupTokenReaper( + deps: ProductionSetupTokenReaperDeps, +): SetupTokenReaper { + const environments = environmentService(deps.db); + const sandbox = createProductionSetupTokenSandboxProvider({ + environments, + environmentRuntime: deps.environmentRuntime, + log: deps.log, + }); + const store = createProductionSetupTokenCleanupStore(deps.db); + return createSetupTokenReaper({ + store, + leases: { releaseById: (leaseId) => sandbox.release(leaseId) }, + log: deps.log, + }); +} diff --git a/server/src/services/setup-token-session.test.ts b/server/src/services/setup-token-session.test.ts index 03b0448d04b..5be8887aa79 100644 --- a/server/src/services/setup-token-session.test.ts +++ b/server/src/services/setup-token-session.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { eq, sql } from "drizzle-orm"; import { - claudeSetupTokenSessions, + adapterAuthSessions, companies, createDb, environments, @@ -50,7 +50,6 @@ const SYNTH_TOKEN = "sk-ant-oat01-SYNTHETICSYNTHETICSYNTHETIC01"; const OWNER_SCOPE: SetupTokenSessionScope = { companyId: "company-1", ownerUserId: "user-1", - targetAgentId: "agent-1", adapterType: "claude_local", environmentId: "env-1", }; @@ -171,7 +170,6 @@ function identityFromScope(scope: SetupTokenSessionScope, sessionId: string): Se companyId: scope.companyId, ownerUserId: scope.ownerUserId, adapterType: scope.adapterType, - environmentId: scope.environmentId, }; } @@ -179,8 +177,7 @@ function identityMatchesRow(row: SetupTokenCleanupRecord, identity: SetupTokenCl return ( row.companyId === identity.companyId && row.ownerUserId === identity.ownerUserId && - row.adapterType === identity.adapterType && - row.environmentId === identity.environmentId + row.adapterType === identity.adapterType ); } @@ -240,7 +237,7 @@ function buildService(overr leases?: L; store?: FakeStore; rateLimiter?: SetupTokenRateLimiter; - caps?: { perOwner: number; perAgent: number; perCompany: number }; + caps?: { perOwner: number; perCompany: number }; ttlMs?: number; tokenRetentionMs?: number; now?: () => number; @@ -276,7 +273,7 @@ function buildService(overr store, completeCredential, rateLimiter: overrides.rateLimiter ?? allowAllRateLimiter(), - caps: overrides.caps ?? { perOwner: 5, perAgent: 5, perCompany: 5 }, + caps: overrides.caps ?? { perOwner: 5, perCompany: 5 }, ttlMs: overrides.ttlMs ?? 60_000, tokenRetentionMs: overrides.tokenRetentionMs, now: overrides.now, @@ -321,10 +318,12 @@ describe("SetupTokenSessionService.start", () => { }); }); - it("rejects a caller over the per-owner cap with 429", async () => { - const { service } = buildService({ caps: { perOwner: 1, perAgent: 5, perCompany: 5 } }); + it("rejects a second start on the same company, owner, and adapter slot with 429", async () => { + const { service } = buildService({ caps: { perOwner: 1, perCompany: 5 } }); await service.start(OWNER_SCOPE); - await expect(service.start({ ...OWNER_SCOPE, targetAgentId: "agent-2" })).rejects.toMatchObject({ + // The second start holds the same company, owner, and adapter slot, so it + // fails closed with the fixed cap error. + await expect(service.start(OWNER_SCOPE)).rejects.toMatchObject({ status: 429, message: SETUP_TOKEN_CAP_EXCEEDED, }); @@ -332,7 +331,7 @@ describe("SetupTokenSessionService.start", () => { }); describe("SetupTokenSessionService.start cap reservation (concurrency)", () => { - const capsPerOwnerOne = { perOwner: 1, perAgent: 5, perCompany: 5 }; + const capsPerOwnerOne = { perOwner: 1, perCompany: 5 }; it("reserves the owner slot synchronously, so a concurrent start fails closed with 429", async () => { const leases = new DeferredLeaseManager(); @@ -506,23 +505,19 @@ describe("SetupTokenSessionService.submitCode", () => { describe("SetupTokenSessionService authorization boundary", () => { const crossCompany: SetupTokenSessionScope = { ...OWNER_SCOPE, companyId: "company-2" }; const sameCompanyOtherUser: SetupTokenSessionScope = { ...OWNER_SCOPE, ownerUserId: "user-9" }; - const otherAgent: SetupTokenSessionScope = { ...OWNER_SCOPE, targetAgentId: "agent-9" }; const otherAdapter: SetupTokenSessionScope = { ...OWNER_SCOPE, adapterType: "codex_local" }; - const otherEnvironment: SetupTokenSessionScope = { ...OWNER_SCOPE, environmentId: "env-9" }; it("returns the same not-found for a cross-scope caller on every operation", async () => { const { service, processes } = buildService(); const { sessionId } = await service.start(OWNER_SCOPE); processes[0].surfacePrompt(FULL_LOGIN_URL); - // The full-scope match rejects a mismatch in any of the five scope fields: - // the company, the owner, the agent, the adapter, and the environment. + // The scope match rejects a mismatch in any of the three identity fields: + // the company, the owner, and the adapter. for (const scope of [ crossCompany, sameCompanyOtherUser, - otherAgent, otherAdapter, - otherEnvironment, ]) { expect(() => service.readPrompt(sessionId, scope)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); expect(() => service.submitCode(sessionId, scope, "code")).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); @@ -539,21 +534,19 @@ describe("SetupTokenSessionService authorization boundary", () => { }); describe("SetupTokenSessionService company-and-environment scope", () => { - // The agentless company scope. It carries a null agent id. - const COMPANY_SCOPE: SetupTokenSessionScope = { ...OWNER_SCOPE, targetAgentId: null }; + const COMPANY_SCOPE: SetupTokenSessionScope = OWNER_SCOPE; const companyKey = { companyId: COMPANY_SCOPE.companyId, ownerUserId: COMPANY_SCOPE.ownerUserId, adapterType: COMPANY_SCOPE.adapterType, }; - it("resolves an agentless session by the company key and returns the intrinsic environment", async () => { + it("resolves a session by the company key and returns the intrinsic environment", async () => { const { service, processes } = buildService(); const { sessionId } = await service.start(COMPANY_SCOPE); processes[0].surfacePrompt(FULL_LOGIN_URL); const scope = service.resolveCompanyScope(sessionId, companyKey); - expect(scope.targetAgentId).toBeNull(); expect(scope.environmentId).toBe(COMPANY_SCOPE.environmentId); const descriptor = service.describeOwned(sessionId, scope); @@ -562,16 +555,6 @@ describe("SetupTokenSessionService company-and-environment scope", () => { expect(descriptor.loginUrl).toBe(FULL_LOGIN_URL); }); - it("rejects an agent-scoped session through the company key", async () => { - const { service } = buildService(); - // The session carries a non-null agent id. The company key requires the - // agentless marker, so a company route cannot reach an agent session. - const { sessionId } = await service.start(OWNER_SCOPE); - expect(() => service.resolveCompanyScope(sessionId, companyKey)).toThrow( - SETUP_TOKEN_SESSION_NOT_FOUND, - ); - }); - it("returns the same not-found for a foreign company key", async () => { const { service } = buildService(); const { sessionId } = await service.start(COMPANY_SCOPE); @@ -1125,7 +1108,6 @@ describe("SetupTokenSessionService durable state scope", () => { companyId: OWNER_SCOPE.companyId, ownerUserId: "intruder", adapterType: OWNER_SCOPE.adapterType, - environmentId: OWNER_SCOPE.environmentId, }, "submitting", ); @@ -1161,7 +1143,7 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" }); afterEach(async () => { - await db.delete(claudeSetupTokenSessions); + await db.delete(adapterAuthSessions); await db.delete(environments); await db.delete(companies); }); @@ -1170,10 +1152,12 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" await stopDb?.(); }); - // Seed a company and an environment, then return one fresh record identity. - async function seedScope(): Promise { + // The seeded scope carries the intrinsic environment. The store record needs + // the environment for the non-null column, but the identity match drops it. + type SeededScope = SetupTokenCleanupIdentity & { environmentId: string }; + + async function seedCompany(): Promise { const companyId = randomUUID(); - const environmentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Acme", @@ -1183,6 +1167,11 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" createdAt: new Date(), updatedAt: new Date(), }); + return companyId; + } + + async function seedEnvironment(): Promise { + const environmentId = randomUUID(); await db.insert(environments).values({ id: environmentId, name: `sandbox-${environmentId.slice(0, 8)}`, @@ -1192,6 +1181,13 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" createdAt: new Date(), updatedAt: new Date(), }); + return environmentId; + } + + // Seed a company and an environment, then return one fresh record scope. + async function seedScope(): Promise { + const companyId = await seedCompany(); + const environmentId = await seedEnvironment(); return { sessionId: randomUUID(), companyId, @@ -1202,13 +1198,13 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" } async function insertRecord( - identity: SetupTokenCleanupIdentity, + scope: SeededScope, state: SetupTokenCleanupRecord["state"], deadlineMs: number, ): Promise { const store = createDbSetupTokenCleanupStore(db); await store.record({ - ...identity, + ...scope, leaseId: "lease-1", deadline: deadlineMs, state, @@ -1219,23 +1215,53 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" async function readRow(sessionId: string) { const rows = await db .select() - .from(claudeSetupTokenSessions) - .where(eq(claudeSetupTokenSessions.sessionId, sessionId)); + .from(adapterAuthSessions) + .where(eq(adapterAuthSessions.publicSessionId, sessionId)); return rows[0]; } - it("records the non-secret cleanup record and reads it back", async () => { - const identity = await seedScope(); - await insertRecord(identity, "starting", Date.now() + 60_000); - const row = await readRow(identity.sessionId); - expect(row?.companyId).toBe(identity.companyId); - expect(row?.ownerUserId).toBe(identity.ownerUserId); - expect(row?.adapterType).toBe(identity.adapterType); - expect(row?.environmentId).toBe(identity.environmentId); - expect(row?.state).toBe("starting"); + it("records the non-secret cleanup record on the unified table and reads it back", async () => { + const scope = await seedScope(); + await insertRecord(scope, "starting", Date.now() + 60_000); + const row = await readRow(scope.sessionId); + // The store writes the unified `adapter_auth_sessions` columns. The public + // session id holds the record session id, and the owner maps to + // `started_by_user_id`, the state to `status`. + expect(row?.publicSessionId).toBe(scope.sessionId); + expect(row?.companyId).toBe(scope.companyId); + expect(row?.startedByUserId).toBe(scope.ownerUserId); + expect(row?.adapterType).toBe(scope.adapterType); + expect(row?.environmentId).toBe(scope.environmentId); + expect(row?.status).toBe("starting"); expect(row?.boundAt).toBeNull(); }); + it("rejects a second active record on the same company, owner, and adapter slot", async () => { + const scope = await seedScope(); + await insertRecord(scope, "awaiting_code", Date.now() + 60_000); + + // A second active record for the same company, owner, and adapter conflicts + // on the active-slot unique index, even from a different environment. This + // proves the slot dropped the environment term. + const secondEnvironment = await seedEnvironment(); + const sameSlot: SeededScope = { + ...scope, + sessionId: randomUUID(), + environmentId: secondEnvironment, + }; + await expect(insertRecord(sameSlot, "awaiting_code", Date.now() + 60_000)).rejects.toThrow(); + + // A different owner in the same company holds an independent slot, so the + // insert succeeds. + const otherOwner: SeededScope = { + ...scope, + sessionId: randomUUID(), + ownerUserId: `user-${randomUUID().slice(0, 8)}`, + }; + await insertRecord(otherOwner, "awaiting_code", Date.now() + 60_000); + expect((await readRow(otherOwner.sessionId))?.startedByUserId).toBe(otherOwner.ownerUserId); + }); + it("consumes a stored claim once with one conditional write", async () => { const identity = await seedScope(); await insertRecord(identity, "stored", Date.now() + 60_000); @@ -1262,6 +1288,31 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" expect((await readRow(identity.sessionId))?.boundAt).toBeNull(); }); + it("returns no row for a cross-company consume and leaves the claim unconsumed", async () => { + const identity = await seedScope(); + await insertRecord(identity, "stored", Date.now() + 60_000); + const store = createDbSetupTokenCleanupStore(db); + + // A claim from another company does not match the stored row. The predicate + // scopes on company_id, so the consume returns no row and leaves bound_at null. + const otherCompanyId = await seedCompany(); + const foreign = await store.consumeStoredClaim({ ...identity, companyId: otherCompanyId }); + expect(foreign).toBeNull(); + expect((await readRow(identity.sessionId))?.boundAt).toBeNull(); + }); + + it("returns no row for a cross-adapter consume and leaves the claim unconsumed", async () => { + const identity = await seedScope(); + await insertRecord(identity, "stored", Date.now() + 60_000); + const store = createDbSetupTokenCleanupStore(db); + + // A claim for a different adapter does not match the stored row. The predicate + // scopes on adapter_type, so the consume returns no row and leaves bound_at null. + const foreign = await store.consumeStoredClaim({ ...identity, adapterType: "codex_local" }); + expect(foreign).toBeNull(); + expect((await readRow(identity.sessionId))?.boundAt).toBeNull(); + }); + it("returns no row for an expired claim", async () => { const identity = await seedScope(); await insertRecord(identity, "stored", Date.now() - 1_000); @@ -1333,12 +1384,12 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)" }); const lockHeld = lockDb.transaction(async (tx) => { await tx.execute( - sql`SELECT bound_at FROM claude_setup_token_sessions WHERE session_id = ${identity.sessionId} FOR UPDATE`, + sql`SELECT bound_at FROM adapter_auth_sessions WHERE public_session_id = ${identity.sessionId} FOR UPDATE`, ); // Set a near deadline inside the locked transaction. The consume cannot see // this value until the transaction commits. await tx.execute( - sql`UPDATE claude_setup_token_sessions SET deadline_at = clock_timestamp() + interval '300 milliseconds' WHERE session_id = ${identity.sessionId}`, + sql`UPDATE adapter_auth_sessions SET expires_at = clock_timestamp() + interval '300 milliseconds' WHERE public_session_id = ${identity.sessionId}`, ); signalLocked(); await gate; diff --git a/server/src/services/setup-token-session.ts b/server/src/services/setup-token-session.ts index 796a37d740e..efe18f3dda6 100644 --- a/server/src/services/setup-token-session.ts +++ b/server/src/services/setup-token-session.ts @@ -9,9 +9,8 @@ // The service gives the harness these operations against the one live session: // start the session, read the login prompt, submit one browser code, cancel the // session, expire the session on a timeout, and receive the token. Every -// operation verifies the company, the owner user, and the target agent. A -// missing session and a cross-scope session both return the same not-found -// error. +// operation verifies the company, the owner user, and the adapter. A missing +// session and a cross-scope session both return the same not-found error. // // Security controls folded here: // * SR-1 (no secret in a log): the service keeps the full login URL, the @@ -35,9 +34,15 @@ import { randomBytes } from "node:crypto"; import { and, eq, gt, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { claudeSetupTokenSessions } from "@paperclipai/db"; +import { adapterAuthSessions } from "@paperclipai/db"; import type { AgentAdapterType } from "@paperclipai/shared"; +// The setup-token login flow supports only the `claude_local` adapter. The +// unified `adapter_auth_sessions` table also holds the Codex device-login rows, +// so every store scan filters by this adapter to reach only the setup-token +// rows. +const SETUP_TOKEN_ADAPTER_TYPE: AgentAdapterType = "claude_local"; + /** * The session states. The four terminal states end the login. The `stored` * state is not terminal: a session enters `stored` after a successful @@ -68,24 +73,24 @@ export function isTerminalSessionState(state: SetupTokenSessionState): boolean { } /** - * The immutable owner scope of a session. Every operation verifies all five - * fields against the stored scope. The service builds the scope once at start - * and never changes it. + * The immutable owner scope of a session. The service builds the scope once at + * start and never changes it. The session identity is the company, the owner, + * and the adapter. Every operation verifies these three fields against the + * stored scope. * - * The `targetAgentId` is null for a company-and-environment login. That login - * has no agent id: a hire flow starts one session before an agent exists. The - * agent-scoped login sets `targetAgentId` to the agent id. The full-scope match - * treats null as one immutable value, so a company login and an agent login - * never resolve one another. + * The active company credential slot is scoped to the company, the owner, and + * the adapter. Two owners in one company no longer conflict. The scope drops the + * environment term from the slot, so the same owner cannot hold one active login + * per environment. */ export interface SetupTokenSessionScope { companyId: string; ownerUserId: string; - targetAgentId: string | null; - // The adapter and the environment of the login. The durable cleanup record - // keys on the company, the owner, the adapter, and the environment, so a hire - // flow with no agent id still resolves one record. + // The adapter of the login. It is part of the session identity. adapterType: string; + // The environment of the login. The store persists it on the row, but it is + // not part of the session identity: a caller never addresses a session by the + // environment, and the active slot no longer includes the environment. environmentId: string; // The optional confirmed-overwrite capture. The client sends it when a stored // token fails the agent test. The secret writer reads it and rotates the @@ -183,9 +188,9 @@ export interface SetupTokenLeaseManager { /** * The non-secret cleanup record. It holds only ids, the deadline, the claim * marker, and the state. It never holds a URL, a code, a token, or a raw process - * chunk. The service persists it at start so a restart can reap the - * lease. The record keys on the company, the owner, the adapter, and the - * environment, not the agent id, so a hire flow with no agent still resolves it. + * chunk. The service persists it at start so a restart can reap the lease. The + * record carries the environment, so the store writes the non-null environment + * column on the row. */ export interface SetupTokenCleanupRecord { sessionId: string; @@ -202,16 +207,15 @@ export interface SetupTokenCleanupRecord { } /** - * The immutable identity of a cleanup record. It is the full owner scope plus - * the session id. Every durable write matches on all five fields, so a write - * never updates a row by session id alone. + * The immutable identity of a cleanup record. It is the company, the owner, the + * adapter, and the public session id. Every durable write matches on all four + * fields, so a write never updates a row by the session id alone. */ export interface SetupTokenCleanupIdentity { sessionId: string; companyId: string; ownerUserId: string; adapterType: string; - environmentId: string; } /** @@ -249,20 +253,66 @@ export interface SetupTokenCleanupStore { consumeStoredClaim(identity: SetupTokenCleanupIdentity): Promise; } +/** The counts one reaper sweep produced over the durable cleanup store. */ +export interface SetupTokenReapResult { + /** The reapable records whose lease released and whose row cleared. */ + released: number; + /** The reapable records whose lease release failed. The row stays for a retry. */ + failed: number; +} + +/** + * The shared setup-token reap logic. It reads the durable store and releases any + * lease whose session is terminal, past its deadline, or already consumed. It + * runs after a restart and on the scheduler interval, so it frees a lease that a + * crash left behind (SR-4). A release failure stays retryable: the sweep leaves + * the record for a later run. The standalone reaper and the in-flight session + * service both call this function, so both flows use one reap convention. + */ +export async function reapSetupTokenLeases( + deps: { + store: Pick; + leases: Pick; + log?: (line: string) => void; + }, + now: number, +): Promise { + const log = deps.log ?? (() => {}); + const records = await deps.store.listReapable(now); + let released = 0; + let failed = 0; + for (const record of records) { + try { + await deps.leases.releaseById(record.leaseId); + await deps.store.remove({ + sessionId: record.sessionId, + companyId: record.companyId, + ownerUserId: record.ownerUserId, + adapterType: record.adapterType, + }); + released += 1; + } catch { + failed += 1; + log("[paperclip] Setup-token reaper: a lease release failed; it stays retryable."); + } + } + return { released, failed }; +} + /** A per-key start rate limiter. It matches the invite-rate-limit shape. */ export interface SetupTokenRateLimiter { consume(key: string): { allowed: boolean; retryAfterSeconds: number }; } export interface SetupTokenSessionCaps { + // The active-session cap per company, owner, and adapter slot. The slot cap + // mirrors the database active-slot unique index as defense in depth. perOwner: number; - perAgent: number; perCompany: number; } export const DEFAULT_SETUP_TOKEN_SESSION_CAPS: SetupTokenSessionCaps = { perOwner: 1, - perAgent: 1, perCompany: 3, }; @@ -523,8 +573,8 @@ export function assessConfidentialStartup(config: ConfidentialTransportConfig): /** * A synchronous capacity hold for the enforced caps. The service reserves the - * capacity for the owner, the agent, and the company before the first `await` in - * {@link SetupTokenSessionService.start}, so two concurrent starts for one owner + * capacity for the slot and the company before the first `await` in + * {@link SetupTokenSessionService.start}, so two concurrent starts for one slot * never both pass the cap. The service holds the reservation for the whole * session lifetime and releases it exactly one time: on an early start failure or * on the terminal cleanup. The `released` flag makes the release idempotent. @@ -532,8 +582,9 @@ export function assessConfidentialStartup(config: ConfidentialTransportConfig): interface CapReservation { released: boolean; companyId: string; - ownerUserId: string; - targetAgentId: string | null; + // The company, owner, and adapter slot key. The reservation counts one active + // session per slot, so it mirrors the database active-slot unique index. + slotKey: string; } interface StoredSession { @@ -635,11 +686,11 @@ function defaultSessionId(): string { */ export class SetupTokenSessionService { private readonly sessions = new Map(); - // The live reservation counts per owner, per agent, and per company. The start - // path reads and increments these synchronously before the first `await`, so - // the cap decision and the capacity hold are one atomic step on the event loop. - private readonly reservedByOwner = new Map(); - private readonly reservedByAgent = new Map(); + // The live reservation counts per slot and per company. The start path reads + // and increments these synchronously before the first `await`, so the cap + // decision and the capacity hold are one atomic step on the event loop. The + // slot key is the company, the owner, and the adapter. + private readonly reservedBySlot = new Map(); private readonly reservedByCompany = new Map(); private readonly factory: SetupTokenLoginProcessFactory; private readonly leases: SetupTokenLeaseManager; @@ -669,8 +720,8 @@ export class SetupTokenSessionService { /** * Builds the durable-record identity for a session. The store matches every - * write on the full owner scope and the session id, so no write updates a row - * by the session id alone. + * write on the company, the owner, the adapter, and the session id, so no write + * updates a row by the session id alone. */ private identityOf(session: StoredSession): SetupTokenCleanupIdentity { return { @@ -678,7 +729,6 @@ export class SetupTokenSessionService { companyId: session.scope.companyId, ownerUserId: session.scope.ownerUserId, adapterType: session.scope.adapterType, - environmentId: session.scope.environmentId, }; } @@ -725,40 +775,38 @@ export class SetupTokenSessionService { } } + /** Builds the company, owner, and adapter slot key. The reservation and the + * database active-slot unique index share this identity. */ + private static slotKey( + scope: Pick, + ): string { + return [scope.companyId, scope.ownerUserId, scope.adapterType].join("\u0000"); + } + /** * Reserves the capacity for one start under every enforced cap. The method is * synchronous, so it runs to completion before the first `await` in - * {@link start}. Two concurrent starts for one owner cannot interleave inside - * it: the first reserves the owner slot, and the second reads the incremented - * count and fails closed with the fixed 429 cap error. The method holds the - * per-owner, per-agent, and per-company semantics: it counts the agent slot - * only for an agent-scoped login, because a null agent id is not a shared key - * across owners. It increments no counter on a rejection, so a rejected start + * {@link start}. Two concurrent starts for one slot cannot interleave inside + * it: the first reserves the slot, and the second reads the incremented count + * and fails closed with the fixed 429 cap error. The method holds the per-slot + * and per-company semantics; the slot is the company, the owner, and the + * adapter. It increments no counter on a rejection, so a rejected start * reserves nothing. */ private reserveCapacity(scope: SetupTokenSessionScope): CapReservation { - if ((this.reservedByOwner.get(scope.ownerUserId) ?? 0) >= this.caps.perOwner) { - throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED); - } - if ( - scope.targetAgentId !== null && - (this.reservedByAgent.get(scope.targetAgentId) ?? 0) >= this.caps.perAgent - ) { + const slotKey = SetupTokenSessionService.slotKey(scope); + if ((this.reservedBySlot.get(slotKey) ?? 0) >= this.caps.perOwner) { throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED); } if ((this.reservedByCompany.get(scope.companyId) ?? 0) >= this.caps.perCompany) { throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED); } - SetupTokenSessionService.incrementCount(this.reservedByOwner, scope.ownerUserId); - if (scope.targetAgentId !== null) { - SetupTokenSessionService.incrementCount(this.reservedByAgent, scope.targetAgentId); - } + SetupTokenSessionService.incrementCount(this.reservedBySlot, slotKey); SetupTokenSessionService.incrementCount(this.reservedByCompany, scope.companyId); return { released: false, companyId: scope.companyId, - ownerUserId: scope.ownerUserId, - targetAgentId: scope.targetAgentId, + slotKey, }; } @@ -766,16 +814,12 @@ export class SetupTokenSessionService { * Releases a capacity reservation exactly one time. The `released` flag makes * the release idempotent, so an early start failure and the terminal cleanup * never double-release one reservation. The method decrements the same counters - * that {@link reserveCapacity} incremented, and it drops the agent counter only - * for an agent-scoped login. + * that {@link reserveCapacity} incremented. */ private releaseReservation(reservation: CapReservation): void { if (reservation.released) return; reservation.released = true; - SetupTokenSessionService.decrementCount(this.reservedByOwner, reservation.ownerUserId); - if (reservation.targetAgentId !== null) { - SetupTokenSessionService.decrementCount(this.reservedByAgent, reservation.targetAgentId); - } + SetupTokenSessionService.decrementCount(this.reservedBySlot, reservation.slotKey); SetupTokenSessionService.decrementCount(this.reservedByCompany, reservation.companyId); } @@ -869,7 +913,6 @@ export class SetupTokenSessionService { companyId: scope.companyId, ownerUserId: scope.ownerUserId, adapterType: scope.adapterType, - environmentId: scope.environmentId, }) .catch(() => {}); this.releaseReservation(reservation); @@ -962,12 +1005,11 @@ export class SetupTokenSessionService { /** * Resolves a session for an operation. It returns the session only when the - * id exists and the stored scope equals the caller scope in all five fields: - * the company, the owner, the agent, the adapter, and the environment. A - * missing session and a cross-scope session both throw the same not-found - * error, so a caller cannot tell them apart. The full-scope match makes - * a cross-adapter and a cross-environment session return the same not-found - * error as a missing session. + * id exists and the stored scope equals the caller scope in all three identity + * fields: the company, the owner, and the adapter. A missing session and a + * cross-scope session both throw the same not-found error, so a caller cannot + * tell them apart. The scope match makes a cross-company, a cross-owner, and a + * cross-adapter session return the same not-found error as a missing session. */ private resolveOwned(sessionId: string, scope: SetupTokenSessionScope): StoredSession { const session = this.sessions.get(sessionId); @@ -975,9 +1017,7 @@ export class SetupTokenSessionService { !session || session.scope.companyId !== scope.companyId || session.scope.ownerUserId !== scope.ownerUserId || - session.scope.targetAgentId !== scope.targetAgentId || - session.scope.adapterType !== scope.adapterType || - session.scope.environmentId !== scope.environmentId + session.scope.adapterType !== scope.adapterType ) { throw new SetupTokenSessionError(404, SETUP_TOKEN_SESSION_NOT_FOUND); } @@ -988,16 +1028,14 @@ export class SetupTokenSessionService { * Resolves the immutable scope of a company-and-environment session. The * caller provides the company, the owner, and the adapter it derived from the * request; the route path gives the company, the actor gives the owner, and - * the route fixes the adapter. The lookup matches these three fields and the - * agentless marker, then returns the full scope, including the intrinsic - * environment. + * the route fixes the adapter. The lookup matches these three fields, then + * returns the full scope, including the intrinsic environment. * * A caller never supplies the environment on a read, a submit, a cancel, or a * completion. The environment is intrinsic to the session, so a foreign * environment cannot address the session. A missing session and a * cross-company, cross-owner, or cross-adapter session all throw the same - * not-found error. The agentless marker rejects an - * agent-scoped session, so a company route never resolves an agent session. + * not-found error. */ resolveCompanyScope( sessionId: string, @@ -1006,7 +1044,6 @@ export class SetupTokenSessionService { const session = this.sessions.get(sessionId); if ( !session || - session.scope.targetAgentId !== null || session.scope.companyId !== key.companyId || session.scope.ownerUserId !== key.ownerUserId || session.scope.adapterType !== key.adapterType @@ -1251,29 +1288,11 @@ export class SetupTokenSessionService { * The startup reaper. It reads the durable store and releases any lease whose * session is terminal or past its deadline. It runs after a restart, so it * frees a lease that a crash left behind (SR-4). A release failure stays - * retryable: the reaper leaves the record for a later run. + * retryable: the reaper leaves the record for a later run. The standalone + * scheduled reaper calls the same {@link reapSetupTokenLeases} logic. */ - async reap(now: number = this.now()): Promise<{ released: number; failed: number }> { - const records = await this.store.listReapable(now); - let released = 0; - let failed = 0; - for (const record of records) { - try { - await this.leases.releaseById(record.leaseId); - await this.store.remove({ - sessionId: record.sessionId, - companyId: record.companyId, - ownerUserId: record.ownerUserId, - adapterType: record.adapterType, - environmentId: record.environmentId, - }); - released += 1; - } catch { - failed += 1; - this.log("[paperclip] Setup-token reaper: a lease release failed; it stays retryable."); - } - } - return { released, failed }; + async reap(now: number = this.now()): Promise { + return reapSetupTokenLeases({ store: this.store, leases: this.leases, log: this.log }, now); } /** @@ -1294,35 +1313,48 @@ export class SetupTokenSessionService { } // --- The durable, database-backed cleanup store ------------------------------ +// +// The store reads and writes the unified `adapter_auth_sessions` table. It maps +// the setup-token record fields to the unified columns: +// - `sessionId` -> `public_session_id` +// - `ownerUserId` -> `started_by_user_id` +// - `leaseId` -> `provider_lease_id` +// - `state` -> `status` +// - `deadline` -> `expires_at` +// The table also holds the Codex device-login rows, so every scan filters by the +// setup-token adapter to reach only the setup-token rows. -type ClaudeSetupTokenSessionRow = typeof claudeSetupTokenSessions.$inferSelect; +type AdapterAuthSessionRow = typeof adapterAuthSessions.$inferSelect; /** Maps a database row to the non-secret cleanup record. */ -function toCleanupRecord(row: ClaudeSetupTokenSessionRow): SetupTokenCleanupRecord { +function toCleanupRecord(row: AdapterAuthSessionRow): SetupTokenCleanupRecord { return { - sessionId: row.sessionId, + sessionId: row.publicSessionId, companyId: row.companyId, - ownerUserId: row.ownerUserId, + ownerUserId: row.startedByUserId, adapterType: row.adapterType, environmentId: row.environmentId, - leaseId: row.leaseId ?? "", - deadline: row.deadlineAt.getTime(), - state: row.state as SetupTokenSessionState, + leaseId: row.providerLeaseId ?? "", + // The setup-token flow always writes an expiry, so a null value means a + // corrupt row. Treat it as already expired, so the reaper reclaims it. + deadline: row.expiresAt ? row.expiresAt.getTime() : 0, + state: row.status as SetupTokenSessionState, boundAt: row.boundAt ? row.boundAt.getTime() : null, }; } /** - * The database scope predicate. It matches the full owner scope and the session - * id, so no write ever addresses a row by the session id alone. + * The database scope predicate. It matches the company, the owner, the adapter, + * and the public session id, so no write ever addresses a row by the session id + * alone. The `company_id` and `started_by_user_id` terms hold the server-scoped + * lookup: a query never keys on the public session id alone. */ function setupTokenScopeMatch(identity: SetupTokenCleanupIdentity) { return and( - eq(claudeSetupTokenSessions.sessionId, identity.sessionId), - eq(claudeSetupTokenSessions.companyId, identity.companyId), - eq(claudeSetupTokenSessions.ownerUserId, identity.ownerUserId), - eq(claudeSetupTokenSessions.adapterType, identity.adapterType as AgentAdapterType), - eq(claudeSetupTokenSessions.environmentId, identity.environmentId), + eq(adapterAuthSessions.publicSessionId, identity.sessionId), + eq(adapterAuthSessions.companyId, identity.companyId), + eq(adapterAuthSessions.startedByUserId, identity.ownerUserId), + eq(adapterAuthSessions.adapterType, identity.adapterType as AgentAdapterType), ); } @@ -1355,14 +1387,14 @@ export async function transitionSetupTokenSessionToStored( identity: SetupTokenCleanupIdentity, ): Promise { const changed = await executor - .update(claudeSetupTokenSessions) - .set({ state: "stored", updatedAt: sql`clock_timestamp()` }) + .update(adapterAuthSessions) + .set({ status: "stored", updatedAt: sql`clock_timestamp()` }) .where( and( setupTokenScopeMatch(identity), - inArray(claudeSetupTokenSessions.state, [...SETUP_TOKEN_STORED_PREDECESSOR_STATES]), - gt(claudeSetupTokenSessions.deadlineAt, sql`clock_timestamp()`), - isNull(claudeSetupTokenSessions.boundAt), + inArray(adapterAuthSessions.status, [...SETUP_TOKEN_STORED_PREDECESSOR_STATES]), + gt(adapterAuthSessions.expiresAt, sql`clock_timestamp()`), + isNull(adapterAuthSessions.boundAt), ), ) .returning(); @@ -1382,66 +1414,73 @@ export function createDbSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore { return { async record(record): Promise { - await db.insert(claudeSetupTokenSessions).values({ - sessionId: record.sessionId, + // The database generates `id`. The store fills `public_session_id` with the + // service session id, which the service builds from a CSPRNG at start. + await db.insert(adapterAuthSessions).values({ companyId: record.companyId, - ownerUserId: record.ownerUserId, - adapterType: record.adapterType as AgentAdapterType, environmentId: record.environmentId, - leaseId: record.leaseId, - state: record.state, - deadlineAt: new Date(record.deadline), + adapterType: record.adapterType as AgentAdapterType, + startedByUserId: record.ownerUserId, + publicSessionId: record.sessionId, + providerLeaseId: record.leaseId, + status: record.state, + expiresAt: new Date(record.deadline), boundAt: record.boundAt === null ? null : new Date(record.boundAt), }); }, async markState(identity, state): Promise { - // The compare-and-set predicate matches the full owner scope, so a write - // never updates a row by the session id alone. + // The compare-and-set predicate matches the company, the owner, and the + // adapter, so a write never updates a row by the session id alone. await db - .update(claudeSetupTokenSessions) - .set({ state, updatedAt: sql`clock_timestamp()` }) + .update(adapterAuthSessions) + .set({ status: state, updatedAt: sql`clock_timestamp()` }) .where(scopeMatch(identity)); }, async remove(identity): Promise { - // The delete matches the full owner scope, so it never removes a row by the - // session id alone. - await db.delete(claudeSetupTokenSessions).where(scopeMatch(identity)); + // The delete matches the company, the owner, and the adapter, so it never + // removes a row by the session id alone. + await db.delete(adapterAuthSessions).where(scopeMatch(identity)); }, async listReapable(now): Promise { // A record is reapable when its session is terminal, its deadline is past, - // or its claim is already consumed. The deadline index supports the scan. + // or its claim is already consumed. The scan filters by the setup-token + // adapter, so it never reaps a Codex device-login row on the shared table. + // The deadline index supports the scan. const rows = await db .select() - .from(claudeSetupTokenSessions) + .from(adapterAuthSessions) .where( - or( - inArray(claudeSetupTokenSessions.state, [...SETUP_TOKEN_TERMINAL_STATES]), - lte(claudeSetupTokenSessions.deadlineAt, new Date(now)), - isNotNull(claudeSetupTokenSessions.boundAt), + and( + eq(adapterAuthSessions.adapterType, SETUP_TOKEN_ADAPTER_TYPE), + or( + inArray(adapterAuthSessions.status, [...SETUP_TOKEN_TERMINAL_STATES]), + lte(adapterAuthSessions.expiresAt, new Date(now)), + isNotNull(adapterAuthSessions.boundAt), + ), ), ); return rows.map(toCleanupRecord); }, async consumeStoredClaim(identity): Promise { - // One conditional write. The predicate carries the full owner scope, the - // session id, `state = stored`, an unexpired deadline, and an unconsumed - // marker. It checks the deadline with `clock_timestamp()`, so the database - // evaluates the current time after any row-lock wait. An expired claim - // cannot pass the deadline condition. The write sets `bound_at` one time - // and returns the row only on a valid consume. + // One conditional write. The predicate carries the company, the owner, the + // adapter, the session id, `status = stored`, an unexpired deadline, and an + // unconsumed marker. It checks the deadline with `clock_timestamp()`, so the + // database evaluates the current time after any row-lock wait. An expired + // claim cannot pass the deadline condition. The write sets `bound_at` one + // time and returns the row only on a valid consume. const changed = await db - .update(claudeSetupTokenSessions) + .update(adapterAuthSessions) .set({ boundAt: sql`clock_timestamp()`, updatedAt: sql`clock_timestamp()` }) .where( and( scopeMatch(identity), - eq(claudeSetupTokenSessions.state, "stored"), - gt(claudeSetupTokenSessions.deadlineAt, sql`clock_timestamp()`), - isNull(claudeSetupTokenSessions.boundAt), + eq(adapterAuthSessions.status, "stored"), + gt(adapterAuthSessions.expiresAt, sql`clock_timestamp()`), + isNull(adapterAuthSessions.boundAt), ), ) .returning(); diff --git a/server/src/services/setup-token-transport-binding.test.ts b/server/src/services/setup-token-transport-binding.test.ts index eeaaf19b48a..c0f2cdb9d13 100644 --- a/server/src/services/setup-token-transport-binding.test.ts +++ b/server/src/services/setup-token-transport-binding.test.ts @@ -30,7 +30,6 @@ import { const SCOPE: SetupTokenSessionScope = { companyId: "company-1", ownerUserId: "user-1", - targetAgentId: null, adapterType: "claude_local", environmentId: "env-1", }; diff --git a/server/src/services/setup-token-transport-binding.ts b/server/src/services/setup-token-transport-binding.ts index 4a74d1bcbe5..a0606a12cd9 100644 --- a/server/src/services/setup-token-transport-binding.ts +++ b/server/src/services/setup-token-transport-binding.ts @@ -36,6 +36,7 @@ import { import { secretService } from "./secrets.js"; import type { environmentService } from "./environments.js"; import type { environmentRuntimeService } from "./environment-runtime.js"; +import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js"; import type { Db } from "@paperclipai/db"; import { createSetupTokenPtyTransport, @@ -200,7 +201,6 @@ export function createSetupTokenSecretWriter(deps: { companyId: scope.companyId, ownerUserId: scope.ownerUserId, adapterType: scope.adapterType, - environmentId: scope.environmentId, }); if (!transitioned) { // Zero-row result: roll back the whole transaction. The session then marks @@ -219,9 +219,11 @@ export function createSetupTokenSecretWriter(deps: { }; } -/** The immutable scope key that correlates one acquire with one factory call. */ +/** The immutable scope key that correlates one acquire with one factory call. It + * is the company, the owner, and the adapter, so it matches the active-slot + * scope. The per-slot session cap is one, so one scope holds one live acquire. */ function scopeKey(scope: SetupTokenSessionScope): string { - return [scope.companyId, scope.ownerUserId, scope.adapterType, scope.environmentId].join("\u0000"); + return [scope.companyId, scope.ownerUserId, scope.adapterType].join("\u0000"); } /** @@ -407,29 +409,29 @@ export function createProductionSetupTokenSandboxProvider( return failClosed(); } - const leaseRecord = await deps.environmentRuntime.acquireRunLease({ - companyId: scope.companyId, - environment, - issueId: null, - agentId: scope.targetAgentId ?? null, - // A null heartbeat run and a null execution workspace disable lease - // reuse, so the login session always runs in a fresh sandbox. - heartbeatRunId: null, - persistedExecutionWorkspace: null, - adapterType: scope.adapterType, - // Apply the active custom-image template, so the sandbox binds to the - // trusted image and runtime identity. - applyCustomImageTemplate: true, - // Re-check the environment company binding inside the lease insert - // transaction. The route guard ran earlier, so a managed reconciliation - // can bind this sandbox to another company between the guard and this - // acquire. The lease insert then rejects a foreign-company environment - // with the 403 `environment_company_mismatch` and holds no lease. - assertCompanyBinding: true, - // Bound the lease expiry to the session deadline. The runtime records - // the earlier of this deadline and the provider expiry on the lease row. - requestedExpiresAt: new Date(deadline), - }); + const leaseRecord = await deps.environmentRuntime.acquireRunLease( + buildLoginLeaseAcquireArgs({ + metadata: { + companyId: scope.companyId, + environment, + adapterType: scope.adapterType, + }, + // The setup-token login is company-and-environment scoped. It carries + // no target agent, so the lease binds no agent. + targetAgentId: null, + // Re-check the environment company binding inside the lease insert + // transaction. The route guard ran earlier, so a managed + // reconciliation can bind this sandbox to another company between the + // guard and this acquire. The lease insert then rejects a + // foreign-company environment with the 403 + // `environment_company_mismatch` and holds no lease. + assertCompanyBinding: true, + // Bound the lease expiry to the session deadline. The runtime records + // the earlier of this deadline and the provider expiry on the lease + // row. + requestedExpiresAt: new Date(deadline), + }), + ); const leaseId = leaseRecord.lease.id; leaseRecords.set(leaseId, leaseRecord); diff --git a/ui/src/api/adapters.ts b/ui/src/api/adapters.ts index 7fdabf9e030..0735075cbd6 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -4,6 +4,17 @@ import { api } from "./client"; +/** + * The safe scalar login fields the server projects for an adapter that declares + * an interactive login capability. The projection carries no function member and + * no secret. The form reads it to pick the login flow and the login panel. + */ +export interface AdapterLoginProjection { + panelMode: "displayed_code" | "submitted_browser_code"; + sandboxTransport: "streamed_exec" | "pseudo_terminal"; + timeoutPolicy: "caller_bounded" | "fixed"; +} + export interface AdapterCapabilities { supportsInstructionsBundle: boolean; supportsSkills: boolean; @@ -11,6 +22,8 @@ export interface AdapterCapabilities { requiresMaterializedRuntimeSkills: boolean; supportsModelProfiles: boolean; supportsAcp: boolean; + /** Present only when the adapter declares an interactive login capability. */ + login?: AdapterLoginProjection; } export interface AcpTargetDescriptor { diff --git a/ui/src/components/AgentConfigForm.render.test.tsx b/ui/src/components/AgentConfigForm.render.test.tsx index f612238f482..60ed57ca94b 100644 --- a/ui/src/components/AgentConfigForm.render.test.tsx +++ b/ui/src/components/AgentConfigForm.render.test.tsx @@ -102,9 +102,27 @@ vi.mock("../adapters", () => ({ }), })); +// The projected login capability per adapter type. The server projects these +// safe scalar fields. `codex_local` drives the displayed-code panel; `claude_local` +// drives the submitted-browser-code panel. A test overrides this map to add a +// third adapter with a projected login capability. +const mockLoginProjections = vi.hoisted( + () => + new Map([ + ["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }], + ["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }], + // A third adapter, not a built-in, with a projected displayed-code login. + ["vendor_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }], + // A non-built-in adapter with a pseudo-terminal login transport. The gate + // requires the provider pty capability from the transport, not the name. + ["pty_vendor_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }], + ]), +); + vi.mock("../adapters/use-adapter-capabilities", () => ({ - useAdapterCapabilities: () => (adapterType: string) => - adapterType === "hermes_gateway" + useAdapterCapabilities: () => (adapterType: string) => { + const login = mockLoginProjections.get(adapterType); + return adapterType === "hermes_gateway" ? { supportsInstructionsBundle: false, supportsSkills: false, @@ -120,7 +138,9 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({ requiresMaterializedRuntimeSkills: false, supportsModelProfiles: true, supportsAcp: true, - }, + ...(login ? { login } : {}), + }; + }, })); vi.mock("../adapters/use-disabled-adapters", () => ({ @@ -318,6 +338,32 @@ const AUTH_MISSING_RESULT = { testedAt: new Date(0).toISOString(), }; +const VENDOR_AUTH_MISSING_RESULT = { + adapterType: "vendor_local", + status: "fail", + checks: [ + { + code: "adapter_auth_missing", + level: "error", + message: "The sandbox has no ready authentication.", + }, + ], + testedAt: new Date(0).toISOString(), +}; + +const PTY_VENDOR_AUTH_MISSING_RESULT = { + adapterType: "pty_vendor_local", + status: "fail", + checks: [ + { + code: "adapter_auth_missing", + level: "error", + message: "The sandbox has no ready authentication.", + }, + ], + testedAt: new Date(0).toISOString(), +}; + const CLAUDE_AUTH_MISSING_RESULT = { adapterType: "claude_local", status: "warn", @@ -341,8 +387,8 @@ const CLAUDE_AUTH_MISSING_RESULT = { // for a provider with the capability. const SANDBOX_CAPABILITIES = getEnvironmentCapabilities(["claude_local", "codex_local"], { sandboxProviders: { - daytona: { supportsSetupTokenLogin: true, displayName: "Daytona" }, - e2b: { supportsSetupTokenLogin: false, displayName: "E2B" }, + daytona: { supportsLoginPty: true, displayName: "Daytona" }, + e2b: { supportsLoginPty: false, displayName: "E2B" }, }, }); @@ -372,6 +418,24 @@ async function renderCodexSandbox(agentOverrides: Partial = {}) { ); } +// A third adapter, not a built-in, in a sandbox environment. Its projected login +// capability drives the login affordance and the displayed-code panel. +async function renderVendorSandbox(agentOverrides: Partial = {}) { + return renderForm( + [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "E2B", + driver: "sandbox", + config: { provider: "e2b" }, + }), + ], + { adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides }, + { showAdapterTestEnvironmentButton: true }, + ); +} + async function renderClaudeSandbox(agentOverrides: Partial = {}) { return renderForm( [ @@ -935,6 +999,31 @@ describe("AgentConfigForm environment selector", () => { expect(findButton(result.container, "Log in")).toBeTruthy(); }); + it("shows the login affordance and the displayed-code panel for a third adapter with a projected login capability", async () => { + // The adapter is not a built-in. Its projected login capability drives the + // login affordance and the panel, so the form reads the capability, not the + // adapter name. The displayed-code panel shows the server code. + mockAgentsApi.testEnvironment.mockResolvedValue(VENDOR_AUTH_MISSING_RESULT); + const result = await renderVendorSandbox(); + roots.push(result.root); + + expect(findButton(result.container, "Log in")).toBeFalsy(); + + await runTest(result.container); + + // The projected capability gates the login affordance on for the third + // adapter. + expect(findButton(result.container, "Log in")).toBeTruthy(); + + await startLogin(result.container); + + // The displayed-code panel shows the one-time code and the authentication + // URL. It shows no browser-code input, so the dispatcher picked the panel + // from the projected `displayed_code` mode. + expect(result.container.textContent).toContain("WXYZ-1234"); + expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy(); + }); + it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Claude sandbox", async () => { mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT); const result = await renderClaudeSandbox(); @@ -973,7 +1062,7 @@ describe("AgentConfigForm environment selector", () => { it("hides the Login button for a Daytona sandbox while the capabilities report no setup-token support", async () => { // Reproduces the reported defect: the Test carries the auth-missing check, - // but the capabilities endpoint reports `supportsSetupTokenLogin: false` + // but the capabilities endpoint reports `supportsLoginPty: false` // for Daytona (a stale persisted plugin manifest). The gate hides the // panel. The server-side fix refreshes the persisted manifest so the // capability reports true and the panel shows (see the companion positive @@ -982,7 +1071,7 @@ describe("AgentConfigForm environment selector", () => { mockEnvironmentsApi.capabilities.mockResolvedValue( getEnvironmentCapabilities(["claude_local", "codex_local"], { sandboxProviders: { - daytona: { supportsSetupTokenLogin: false, displayName: "Daytona" }, + daytona: { supportsLoginPty: false, displayName: "Daytona" }, }, }), ); @@ -1006,6 +1095,57 @@ describe("AgentConfigForm environment selector", () => { expect(findButton(result.container, "Log in")).toBeFalsy(); }); + it("gates a pseudo-terminal login on the provider pty capability for a non-Claude adapter", async () => { + // The gate reads the adapter login transport, not the adapter name. This + // adapter is not `claude_local`, but its login runs on a pseudo-terminal. + // The E2B provider reports no pty capability, so the panel stays hidden even + // after the auth-missing check. + mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT); + const result = await renderForm( + [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "E2B", + driver: "sandbox", + config: { provider: "e2b" }, + }), + ], + { adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" }, + { showAdapterTestEnvironmentButton: true }, + ); + roots.push(result.root); + + await runTest(result.container); + + expect(findButton(result.container, "Log in")).toBeFalsy(); + }); + + it("shows a pseudo-terminal login for a non-Claude adapter when the provider advertises pty support", async () => { + // The same non-Claude pseudo-terminal adapter on Daytona. Daytona advertises + // the pty capability, so the panel shows. This confirms the gate follows the + // provider capability, not the adapter name. + mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT); + const result = await renderForm( + [ + makeEnvironment({ id: "local-1", name: "Local", driver: "local" }), + makeEnvironment({ + id: "sandbox-1", + name: "Daytona", + driver: "sandbox", + config: { provider: "daytona" }, + }), + ], + { adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" }, + { showAdapterTestEnvironmentButton: true }, + ); + roots.push(result.root); + + await runTest(result.container); + + expect(findButton(result.container, "Log in")).toBeTruthy(); + }); + it("shows the Login button when a parent lifts the test feedback and renders the panel from the descriptor", async () => { // The create page hides the inline feedback branch and renders the test // result and the login panel itself. This harness mirrors that parent: it diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index ff0a83f177d..aded4092e30 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -10,7 +10,7 @@ import type { EnvSecretRefBinding, Environment, } from "@paperclipai/shared"; -import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode } from "@paperclipai/shared"; +import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared"; import type { AdapterModel } from "../api/agents"; import { agentsApi } from "../api/agents"; import { ApiError } from "../api/client"; @@ -22,7 +22,6 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/a 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"; -import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/adapter-codex-local/ui"; import { Popover, PopoverContent, @@ -587,17 +586,20 @@ export function AgentConfigForm(props: AgentConfigFormProps) { () => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null, [environments, effectiveLoginEnvironmentId], ); - // Load the sandbox provider capabilities. The Claude setup-token login runs on - // a real pseudo-terminal, so it needs a provider that advertises the - // setup-token login capability. The login panel gate reads this to hide the - // panel for a provider without the capability. The gate is advisory; the - // server resolves the capability again and fails closed. + // Load the sandbox provider capabilities. A login that runs on a real + // pseudo-terminal needs a provider that advertises the login pseudo-terminal + // capability. The login panel gate reads this to hide the panel for a provider + // without the capability. Enable the query from the adapter login transport, + // not the adapter name: only a pseudo-terminal login consults this data. The + // gate is advisory; the server resolves the capability again and fails closed. const { data: environmentCapabilities } = useQuery({ queryKey: selectedCompanyId ? queryKeys.environments.capabilities(selectedCompanyId) : ["environment-capabilities", "none"], queryFn: () => environmentsApi.capabilities(selectedCompanyId!), - enabled: Boolean(selectedCompanyId) && adapterType === "claude_local", + enabled: + Boolean(selectedCompanyId) && + adapterCaps.login?.sandboxTransport === "pseudo_terminal", }); // When the instance forces Kubernetes execution, new agents must default to the @@ -939,35 +941,40 @@ export function AgentConfigForm(props: AgentConfigFormProps) { clearClaudeLoginClaimRef.current(); }, [adapterType, effectiveLoginEnvironmentId]); - // Show the login affordance only for a current `codex_local` or `claude_local` - // sandbox whose most recent Test result carries the canonical auth-missing - // check. Both adapters emit the same neutral code. The result keeps its own - // `adapterType`, so a result from another adapter never gates the panel. - const adapterSupportsSandboxLogin = - adapterType === "codex_local" || adapterType === "claude_local"; + // Show the login affordance only for a current sandbox adapter that declares a + // login capability, and whose most recent Test result carries the canonical + // auth-missing check. The form reads the projected capability, not the adapter + // name. The result keeps its own `adapterType`, so the form reads the + // capability for that result adapter; a result from an adapter with no login + // capability never gates the panel. + const adapterSupportsSandboxLogin = adapterCaps.login != null; + const testResult = testEnvironment.data; + const testResultSupportsSandboxLogin = + testResult != null && getCapabilities(testResult.adapterType).login != null; const authMissingCheck = - testEnvironment.data?.adapterType === "codex_local" || - testEnvironment.data?.adapterType === "claude_local" - ? testEnvironment.data.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null + testResult && testResultSupportsSandboxLogin + ? testResult.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null : null; - // The Claude login runs on a real pseudo-terminal, so it needs a provider that - // advertises the setup-token login capability. Read the capability for the - // effective environment provider. The codex login uses a different flow and - // does not gate on this capability, so the requirement applies to Claude only. + // A login that runs on a real pseudo-terminal needs a provider that advertises + // the login pseudo-terminal capability. Read the capability for the effective + // environment provider. The form reads the adapter login transport, not the + // adapter name: a login with a streamed-exec transport does not gate on this + // capability, so the requirement applies to a pseudo-terminal login only. const effectiveLoginProvider = typeof effectiveLoginEnvironment?.config?.provider === "string" ? effectiveLoginEnvironment.config.provider : null; - const providerSupportsSetupTokenLogin = + const providerSupportsLoginPty = effectiveLoginProvider != null && - environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsSetupTokenLogin === true; + environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true; + const loginNeedsPty = adapterCaps.login?.sandboxTransport === "pseudo_terminal"; const showAdapterLogin = adapterSupportsSandboxLogin && effectiveLoginEnvironment?.driver === "sandbox" && Boolean(effectiveLoginEnvironmentId) && Boolean(selectedCompanyId) && Boolean(authMissingCheck) && - (adapterType !== "claude_local" || providerSupportsSetupTokenLogin); + (!loginNeedsPty || providerSupportsLoginPty); const runEnvironmentTest = useCallback(async () => { if (!selectedCompanyId) { throw new Error("Select a company to test adapter environment"); @@ -2011,11 +2018,13 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & { onApplyStored?: () => void; }; -// The login panel dispatcher. It picks the panel mode from the adapter. The -// Claude adapter shows the submitted-browser-code panel. Every other adapter -// shows the displayed-code panel. +// The login panel dispatcher. It picks the panel from the projected panel mode, +// not from the adapter name. The `submitted_browser_code` mode shows the +// submitted-browser-code panel; every other mode shows the displayed-code panel. export function AdapterLoginPanel(props: AdapterLoginPanelProps) { - if (props.adapterType === "claude_local") { + const getCapabilities = useAdapterCapabilities(); + const panelMode = getCapabilities(props.adapterType).login?.panelMode; + if (panelMode === "submitted_browser_code") { return ; } return ; diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index d883f5572ea..e01ccc43a1b 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -193,7 +193,7 @@ const environmentCapabilities: EnvironmentCapabilities = { interactiveSetupConnectionTypes: [], supportsTemplateCapture: false, supportsTemplateDelete: false, - supportsSetupTokenLogin: false, + supportsLoginPty: false, displayName: "Fake", source: "builtin", }, @@ -207,7 +207,7 @@ const environmentCapabilities: EnvironmentCapabilities = { interactiveSetupConnectionTypes: ["ssh"], supportsTemplateCapture: true, supportsTemplateDelete: true, - supportsSetupTokenLogin: true, + supportsLoginPty: true, displayName: "Daytona", source: "plugin", }, diff --git a/ui/src/pages/NewAgent.test.tsx b/ui/src/pages/NewAgent.test.tsx index 56160cd5936..7391237175d 100644 --- a/ui/src/pages/NewAgent.test.tsx +++ b/ui/src/pages/NewAgent.test.tsx @@ -76,6 +76,33 @@ vi.mock("../adapters/use-disabled-adapters", () => ({ useDisabledAdaptersSync: () => new Set(), })); +// The form reads the projected adapter login capability to pick the login flow +// and to gate the login panel. The server projects these safe scalar fields. +// `claude_local` runs on a real pseudo-terminal, so the panel gate requires the +// provider pty capability. Provide the projection so the login panel renders. +const mockLoginProjections = vi.hoisted( + () => + new Map([ + ["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }], + ["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }], + ]), +); + +vi.mock("../adapters/use-adapter-capabilities", () => ({ + useAdapterCapabilities: () => (adapterType: string) => { + const login = mockLoginProjections.get(adapterType); + return { + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + supportsModelProfiles: true, + supportsAcp: true, + ...(login ? { login } : {}), + }; + }, +})); + vi.mock("../components/MarkdownEditor", () => ({ MarkdownEditor: ({ value, @@ -164,8 +191,8 @@ const CLAUDE_AUTH_MISSING_RESULT = { // for a provider with the capability, so the sandbox environment uses Daytona. const SANDBOX_CAPABILITIES = getEnvironmentCapabilities(["claude_local", "codex_local"], { sandboxProviders: { - daytona: { supportsSetupTokenLogin: true, displayName: "Daytona" }, - e2b: { supportsSetupTokenLogin: false, displayName: "E2B" }, + daytona: { supportsLoginPty: true, displayName: "Daytona" }, + e2b: { supportsLoginPty: false, displayName: "E2B" }, }, }); From a5f3ac6b48e4c1c25a3ef03f06e8ed4e4c781530 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:18:50 -0700 Subject: [PATCH 33/39] chore(lockfile): refresh pnpm-lock.yaml (#11737) Auto-generated lockfile refresh after dependencies changed on master. This PR only updates pnpm-lock.yaml. Co-authored-by: lockfile-bot --- pnpm-lock.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24add437e53..3a7ea2ff61c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: '@paperclipai/adapter-utils': specifier: workspace:* version: link:../../adapter-utils + '@paperclipai/shared': + specifier: workspace:* + version: link:../../shared picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -163,6 +166,9 @@ importers: '@paperclipai/adapter-utils': specifier: workspace:* version: link:../../adapter-utils + '@paperclipai/shared': + specifier: workspace:* + version: link:../../shared picocolors: specifier: ^1.1.1 version: 1.1.1 From 233be4b36ce24e75df034d7811460e19cf8e734f Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 19 Aug 2026 12:34:11 -0700 Subject: [PATCH 34/39] feat: parallelize sandbox file-sync behind a provider opt-in capability (#11736) ## Thinking Path > - Paperclip runs AI agents through local and remote execution adapters. > - Sandbox providers move workspace and asset files before and after agent runs. > - Serial file transfers delay startup and teardown when several operations do not depend on each other. > - Providers need an opt-in contract so existing providers keep their serial behavior. > - This pull request adds a bounded scheduler and routes inbound and outbound sync operations through it. > - The benefit is shorter sandbox setup and teardown with stable errors, clear telemetry, and a safe opt-in path. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting (multiple of the above): packages/shared, packages/adapter-utils, packages/plugins, and server. **Problem or motivation** Sandbox sync processes the workspace, assets, and referenced projects in series. This adds avoidable wait time to agent startup and teardown. **Proposed solution** Add a fail-closed provider capability named concurrentSyncOperations. Use a bounded scheduler with a limit of four operations. Preserve operation order for error reporting. Keep non-opted-in providers on the serial path. **Alternatives considered** Increase the serial transfer speed or add provider-specific schedulers. Those options do not provide one shared contract or stable behavior across providers. **Roadmap alignment** ROADMAP.md lists cloud and sandbox agents as a product area. This change improves sandbox execution without changing the control-plane contract. **Additional context** The Daytona provider opts in. Board trials on this commit showed overlap for inbound sync and outbound restore, with no referenced-project staging failures. ## What Changed - Add the concurrentSyncOperations sandbox capability and fail-closed parsing. - Add a bounded settle-all scheduler with stable input-order errors. - Parallelize inbound workspace, asset, and referenced-project sync operations when the provider opts in. - Parallelize outbound workspace and asset restore operations when the provider opts in. - Surface referenced-project failure text in run logs and server telemetry. - Add Daytona sync spans and the capability declaration. - Preserve in-flight upload scratch tarballs during workspace wipe. - Add unit and regression tests for the scheduler, coordinators, provider behavior, telemetry, and wipe race. ## Verification - Run the adapter-utils and server type checks. - Run the targeted adapter-utils, server, and Daytona test suites. - Run the full automated sweep. - Review six cold Daytona trials, with three serial and three parallel runs. - Confirm that parallel trials show inbound overlap and outbound restore overlap. - Confirm that providers without the capability keep serial behavior. ## Risks - Providers must opt in only when their file operations can run safely at the same time. - A provider that declares the capability incorrectly can expose transfer races. - The scheduler keeps a limit of four to bound resource use. - Providers without the capability keep the prior serial behavior. ## Model Used OpenAI GPT-5 in the Codex runtime. The model used tool calls, code inspection, and GitHub workflow support. The model did not author the implementation commits. ## 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 - [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 | 157 ++- .../adapter-utils/src/acpx-engine/execute.ts | 24 +- .../src/acpx-engine/run-contracts.ts | 2 + .../src/acpx-engine/run-site-sandbox.test.ts | 57 + .../src/acpx-engine/run-site-sandbox.ts | 2 +- .../src/acpx-engine/startup-timing.ts | 5 + .../src/command-managed-runtime.test.ts | 56 + .../src/command-managed-runtime.ts | 16 + .../adapter-utils/src/execution-target.ts | 2 + .../src/sandbox-managed-runtime.test.ts | 1078 ++++++++++++++++- .../src/sandbox-managed-runtime.ts | 911 ++++++++------ .../src/sync-operation-schedule.test.ts | 166 +++ .../src/sync-operation-schedule.ts | 79 ++ packages/adapter-utils/src/types.ts | 7 +- .../daytona/src/file-sync.test.ts | 155 +++ .../daytona/src/file-sync.ts | 153 ++- .../sandbox-providers/daytona/src/manifest.ts | 24 +- .../daytona/src/plugin.test.ts | 391 ++++++ packages/shared/src/telemetry/README.md | 41 +- packages/shared/src/types/plugin.ts | 10 + packages/shared/src/validators/plugin.test.ts | 35 +- packages/shared/src/validators/plugin.ts | 1 + ...ment-execution-target-capabilities.test.ts | 40 + .../__tests__/heartbeat-project-env.test.ts | 5 +- .../plugin-host-services-span.test.ts | 17 + .../sandbox-capability-contract.test.ts | 35 + .../services/environment-execution-target.ts | 6 + server/src/services/environment-runtime.ts | 10 + server/src/services/heartbeat.ts | 12 + server/src/services/plugin-host-services.ts | 13 +- 30 files changed, 3032 insertions(+), 478 deletions(-) create mode 100644 packages/adapter-utils/src/sync-operation-schedule.test.ts create mode 100644 packages/adapter-utils/src/sync-operation-schedule.ts create mode 100644 packages/plugins/sandbox-providers/daytona/src/file-sync.test.ts diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index a61510c70b1..2d3a970181d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -2594,6 +2594,42 @@ describe("ACPX engine remote managed-home seam (PR 2: per-adapter home seed)", ( expect(stageArgs.assets ?? []).toEqual([]); expect(sessionInputs[0]?.cwd).toBe(remoteCwd); }); + + it("test_remote_seam_surfaces_referenced_project_staging_failure_reason", async () => { + // A referenced project that failed to stage into the sandbox is a first-class + // run outcome. The run result carries the failure reason for each dropped + // project, and the run log gains one stderr line per failure that names the + // project id and the reason. The run stays successful (per-project isolation). + const { stateDir, localCwd, executionTarget } = await setupRemoteSandbox(); + const { result, logs } = await runExecutor( + { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, + { + authToken: "real-run-jwt", + executionTarget, + prepareRemoteManagedHome: async (input) => { + const stagedRuntime = await input.stage([]); + // Inject a per-project staging failure the coordinator would record on a + // real sandbox extract failure. + stagedRuntime.additionalSourceFailures = [ + { projectId: "proj-x", error: "extract failed: boom" }, + ]; + return { stagedRuntime }; + }, + }, + ); + + // The run result carries the failure with its reason. + expect(result.referencedProjectStagingFailures).toEqual([ + { projectId: "proj-x", error: "extract failed: boom" }, + ]); + // The run log gains one stderr line that names the project id and the reason. + const failureLine = logs.find( + (entry) => entry.stream === "stderr" && entry.text.includes("proj-x"), + ); + expect(failureLine?.text).toBe( + "[paperclip] Referenced project proj-x failed to stage; the run continues without it: extract failed: boom\n", + ); + }); }); describe("ACPX engine remote session-lifecycle re-staging (PR 3: stage once / reuse on compatible resume)", () => { @@ -3349,14 +3385,27 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = expect(turnSpan!.parent).toBe(runRootSpan); expect(turnSpan!.ended).toBe(true); + // The settlement sync-back opens the `sandbox.syncBack` span. It runs at + // teardown, so it parents to the run root span, not to the bring-up span. + const syncBackSpan = spans.find((span) => span.name === "sandbox.syncBack"); + expect(syncBackSpan).toBeTruthy(); + expect(syncBackSpan!.parent).toBe(runRootSpan); + expect(syncBackSpan!.ended).toBe(true); + // 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. The `stage.sync` step also opens three host - // sub-step spans — `snapshot.git`, `snapshot.baseline`, and `pack` — around - // its git enumeration, baseline content-hash walk, and workspace tarball - // build, so those nest one level deeper. + // root or the turn span. The `stage.sync` step also opens the two pre-task + // sub-step spans — `snapshot.git` and `snapshot.baseline` — plus the + // `stage.workspace` inbound task span, and the `pack` span nests one level + // deeper under `stage.workspace`. const childNames = spans - .filter((span) => span !== runRootSpan && span !== startupSpan && span !== turnSpan) + .filter( + (span) => + span !== runRootSpan && + span !== startupSpan && + span !== turnSpan && + span !== syncBackSpan, + ) .map((span) => span.name) .sort(); expect(childNames).toEqual( @@ -3370,32 +3419,44 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = "snapshot.baseline", "snapshot.git", "stage.sync", + "stage.workspace", "workspace.resolve", ], ); - // The three host sub-step spans nest under the `stage.sync` step span (that - // host work runs inside the step), not directly under the bring-up span. + // The two pre-task sub-step spans and the `stage.workspace` task span nest + // under the `stage.sync` step span (that host work runs inside the step), not + // directly under the bring-up span. The `pack` span nests under + // `stage.workspace`, because the host builds the tarball inside the task. const stageSyncSpan = spans.find((span) => span.name === "stage.sync"); + const stageWorkspaceSpan = spans.find((span) => span.name === "stage.workspace"); const packSpan = spans.find((span) => span.name === "pack"); const snapshotGitSpan = spans.find((span) => span.name === "snapshot.git"); const snapshotBaselineSpan = spans.find((span) => span.name === "snapshot.baseline"); expect(stageSyncSpan).toBeTruthy(); + expect(stageWorkspaceSpan).toBeTruthy(); expect(packSpan).toBeTruthy(); expect(snapshotGitSpan).toBeTruthy(); expect(snapshotBaselineSpan).toBeTruthy(); - expect(packSpan!.parent).toBe(stageSyncSpan); + expect(stageWorkspaceSpan!.parent).toBe(stageSyncSpan); + expect(packSpan!.parent).toBe(stageWorkspaceSpan); expect(snapshotGitSpan!.parent).toBe(stageSyncSpan); expect(snapshotBaselineSpan!.parent).toBe(stageSyncSpan); expect(packSpan!.ended).toBe(true); // Every boundary step span parents to the sandbox bring-up span and ends. - // The three `stage.sync` sub-step spans are the exceptions: they parent to - // `stage.sync` above. - const stageSyncChildren = new Set([packSpan, snapshotGitSpan, snapshotBaselineSpan]); + // The `stage.sync` sub-step spans and the run-parented `sandbox.syncBack` + // span are the exceptions: they parent above. + const nonStartupChildren = new Set([ + packSpan, + stageWorkspaceSpan, + snapshotGitSpan, + snapshotBaselineSpan, + syncBackSpan, + ]); for (const span of spans) { if (span === runRootSpan || span === startupSpan || span === turnSpan) continue; - if (stageSyncChildren.has(span)) continue; + if (nonStartupChildren.has(span)) 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); } @@ -4139,6 +4200,78 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = expect(execParents.size).toBe(4); }); + it("test_sync_back_runs_inside_sandbox_syncback_span", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const localCwd = path.join(root, "worktree"); + const codexHome = path.join(root, "codex-home"); + await fs.mkdir(localCwd, { recursive: true }); + await fs.mkdir(codexHome, { recursive: true }); + const executionTarget = await remoteSandboxTarget(root); + const { traceContext, spans } = createRecordingStartupTrace(); + + // The settlement `syncBack` step runs the managed-home teardown. The teardown + // issues one exec through the same host-to-sandbox seam the run uses. The + // recorder captures the exec parent, so the test proves the teardown runs + // inside the run-parented `sandbox.syncBack` span. + let teardownExecFired = false; + const execute = createAcpxEngineExecutor({ + prepareRemoteManagedHome: async (input) => { + const stagedRuntime = await input.stage([]); + return { + stagedRuntime, + teardown: async () => { + if (!teardownExecFired) { + teardownExecFired = true; + issueSandboxExecFromStore(traceContext); + } + }, + }; + }, + createRuntime: () => buildRuntime() as never, + }); + + const result = await execute({ + runId: "run-sync-back-span", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "codex", + agentCommand: "node ./fake-acp.js", + stateDir, + cwd: localCwd, + env: { CODEX_HOME: codexHome }, + }, + context: {}, + authToken: "real-run-jwt", + executionTarget, + startupTraceContext: traceContext, + onLog: async () => {}, + onMeta: async () => {}, + onEvent: async () => {}, + } as never); + expect(result.exitCode).toBe(0); + expect(teardownExecFired).toBe(true); + + // One `sandbox.syncBack` span opens, ends, and parents to `task.run`. The + // settlement runs the teardown after the turn, so the run parent is + // `task.run` at that time. + const runSpan = spans.find((span) => span.name === "task.run"); + expect(runSpan).toBeTruthy(); + const syncBackSpans = spans.filter((span) => span.name === "sandbox.syncBack"); + expect(syncBackSpans).toHaveLength(1); + const syncBackSpan = syncBackSpans[0]!; + expect(syncBackSpan.ended).toBe(true); + expect(syncBackSpan.parent).toBe(runSpan); + + // The teardown exec parents to `sandbox.syncBack`, so the sync-back copy-back + // runs inside the span, not unparented at teardown. + const syncBackExec = spans.find( + (span) => span.name === "sandbox.exec" && span.parent === syncBackSpan, + ); + expect(syncBackExec, "the teardown exec must parent to sandbox.syncBack").toBeTruthy(); + }); + it("test_no_sandbox_exec_parents_to_http_root", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 47118fbf753..aa1878a7682 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -3326,7 +3326,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { let resumedSession = false; let clearSession = false; let referencedProjectStagingFailuresField: - | { referencedProjectStagingFailures: Array<{ projectId: string }> } + | { referencedProjectStagingFailures: Array<{ projectId: string; error: string }> } | Record = {}; const recordTeardownError = async (step: string, teardownErr: unknown) => { const reason = teardownErr instanceof Error ? teardownErr.message : String(teardownErr); @@ -3432,9 +3432,19 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // is a failure to report. const referencedProjectStagingFailures = ( prepared.stagedRuntime?.additionalSourceFailures ?? [] - ).map((failure) => ({ projectId: failure.projectId })); + ).map((failure) => ({ projectId: failure.projectId, error: failure.error })); referencedProjectStagingFailuresField = referencedProjectStagingFailures.length > 0 ? { referencedProjectStagingFailures } : {}; + // Write one run-log line per failure, so a reader of the run log alone sees + // each dropped referenced project and its reason. The run continues without + // the failed project (per-project failure isolation). Goes to stderr: the + // acpx stdout log stream carries machine-parseable JSON event payloads. + for (const failure of referencedProjectStagingFailures) { + await ctx.onLog( + "stderr", + `[paperclip] Referenced project ${failure.projectId} failed to stage; the run continues without it: ${failure.error}\n`, + ); + } // State the effective wall-clock timeout and its source up front so a // later timeout is diagnosable from the run log alone. Goes to stderr: // the acpx stdout log stream carries JSON acpx.* event payloads and must @@ -4226,9 +4236,15 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { stopTransport: () => timedPhase("stop_transport", async () => { await stopRunTransport(prepared); }), - // The site sync-back (the managed-home copy-back). + // The site sync-back (the managed-home copy-back). The run-parented span + // runner wraps the restore in a `sandbox.syncBack` span. The runner also + // publishes the run parent into the runtime-parent store while the restore + // runs, so the host mints a `traceparent` for the provider spans, and the + // per-task restore spans parent to `sandbox.syncBack`. syncBack: () => timedPhase("sync_back", async () => { - await syncBackManagedHome(prepared); + await runRuntimeSpan("sandbox.syncBack", async () => { + await syncBackManagedHome(prepared); + }); }), // The staging lease releases as the run's final act, AFTER the coordinator // reproduces the result, in the run root `finally` below. This step stays a diff --git a/packages/adapter-utils/src/acpx-engine/run-contracts.ts b/packages/adapter-utils/src/acpx-engine/run-contracts.ts index a82501bd47e..afc56f448a7 100644 --- a/packages/adapter-utils/src/acpx-engine/run-contracts.ts +++ b/packages/adapter-utils/src/acpx-engine/run-contracts.ts @@ -325,6 +325,8 @@ export interface SitePlan { /** One referenced project whose staging into the sandbox failed. */ export interface ReferencedProjectStagingFailure { readonly projectId: string; + /** The failure reason. A reader of the run sees why the project dropped. */ + readonly error: string; } /** diff --git a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts index 14ad2ff1cc0..426a3342761 100644 --- a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts +++ b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.test.ts @@ -140,6 +140,23 @@ describe("sandbox run site", () => { expect(scopeOf("agent_bridge")).toBe("per_run"); }); + it("test_sandbox_site_place_workspace_carries_referenced_project_failure_reason", async () => { + // A referenced project that fails to stage carries its failure reason back on + // the placed-workspace result, so a reader of the run learns why it dropped. + const failedStaged = { + runtimeRootDir: "/remote/fail/.paperclip-runtime/acpx", + additionalSourceDirs: {}, + additionalSourceFailures: [{ projectId: "proj-x", error: "extract failed: boom" }], + } as unknown as PreparedAdapterExecutionTargetRuntime; + const { site } = makeSite({ stage: async () => failedStaged }); + + const placed = await site.placeWorkspace(makeContext("s")); + + expect(placed).toEqual({ + referencedProjectStagingFailures: [{ projectId: "proj-x", error: "extract failed: boom" }], + }); + }); + it("test_sandbox_site_preserves_bridge_overlap_and_callback_sequencing", async () => { const events: string[] = []; let releasePaperclip!: () => void; @@ -181,6 +198,46 @@ describe("sandbox run site", () => { expect(transport.launchEnv).toEqual(processLaunchEnv); }); + it("test_sandbox_site_session_new_starts_only_after_the_sync_barrier_settles", async () => { + // The staging step wraps the inbound sync coordinator. Hold it open with a + // gate, so the sync barrier stays unsettled. `placeWorkspace` awaits staging, + // so it stays pending while the gate is held. + let releaseStaging!: () => void; + const stagingGate = new Promise((resolve) => { + releaseStaging = resolve; + }); + const freshStaged = makeStagedRuntime("fresh"); + const { site, bridgeCalls } = makeSite({ + stage: async () => { + await stagingGate; + return freshStaged; + }, + }); + + // Drive the real run order: the engine awaits `placeWorkspace`, then starts the + // transport. `startProcessSessionBridge` models the ACP `session/new` startup. + let placed = false; + const run = (async () => { + await site.placeWorkspace(makeContext("s")); + placed = true; + await site.startTransport(makeContext("s")); + })(); + run.catch(() => undefined); + + // The sync barrier is still open, so `placeWorkspace` has not resolved and the + // process-session bridge that starts `session/new` has not started. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(placed).toBe(false); + expect(bridgeCalls).not.toContain("process-session:start"); + + // Settle the sync barrier. `placeWorkspace` resolves, then the transport starts + // `session/new`. + releaseStaging(); + await run; + expect(placed).toBe(true); + expect(bridgeCalls).toContain("process-session:start"); + }); + it("test_sandbox_site_borrow_yields_files_and_runtime_is_still_created", async () => { const cachedRuntime = makeStagedRuntime("cached"); const stagedRuntimes = new Map([ diff --git a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts index 58f7a0f2c36..4f85022607d 100644 --- a/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts +++ b/packages/adapter-utils/src/acpx-engine/run-site-sandbox.ts @@ -320,7 +320,7 @@ export function createSandboxRunSite(options: SandboxRunSiteOptions): SandboxRun } return { referencedProjectStagingFailures: (staged.stagedRuntime.additionalSourceFailures ?? []).map( - (failure) => ({ projectId: failure.projectId }), + (failure) => ({ projectId: failure.projectId, error: failure.error }), ), }; }, diff --git a/packages/adapter-utils/src/acpx-engine/startup-timing.ts b/packages/adapter-utils/src/acpx-engine/startup-timing.ts index 5e70bdbdb3d..131c7cf6215 100644 --- a/packages/adapter-utils/src/acpx-engine/startup-timing.ts +++ b/packages/adapter-utils/src/acpx-engine/startup-timing.ts @@ -109,6 +109,11 @@ export const SANDBOX_STARTUP_SPAN_ATTRS = { transferWallMs: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.wall_ms`, /** The number of serial guard round trips before one transfer. */ transferGuardCount: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.guard.count`, + /** The transfer direction: `inbound` for an upload to the sandbox, `outbound` + * for a download from the sandbox. The parent span carries operation identity, + * so the transfer span never carries an operation label. The value stays in a + * closed set, so the attribute cardinality is bounded. */ + transferDirection: `${SANDBOX_STARTUP_SPAN_ATTR_PREFIX}transfer.direction`, } as const; /** The closed value set for the `outcome` attribute. */ diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 859ea127582..2666d20ae78 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -552,6 +552,62 @@ describe("command managed runtime", () => { expect(native.syncOut).toBeTypeOf("function"); }); + it("base64 fallback client reports allowConcurrentSyncOperations true", () => { + // A runner with no native sync uses the base64 fallback, which always + // permits concurrent sync operations. + const base = makeSpawnRunner().runner; + const client = createCommandManagedRuntimeClient({ runner: base, commandCwd: "/", timeoutMs: 1 }); + expect(client.allowConcurrentSyncOperations).toBe(true); + }); + + it("undeclared native runner reports allowConcurrentSyncOperations false", () => { + // A native runner (both sync verbs) that never opted into concurrency keeps + // the flag off. + const base = makeSpawnRunner().runner; + const undeclaredNative: CommandManagedRuntimeRunner = { + ...base, + syncIn: async () => ({ operations: [] }), + syncOut: async () => ({ operations: [] }), + }; + const client = createCommandManagedRuntimeClient({ + runner: undeclaredNative, + commandCwd: "/", + timeoutMs: 1, + }); + expect(client.allowConcurrentSyncOperations).toBe(false); + }); + + it("declared native runner reports allowConcurrentSyncOperations true", () => { + // A native runner that verified the opt-in carries the flag through to the + // client. + const base = makeSpawnRunner().runner; + const declaredNative: CommandManagedRuntimeRunner = { + ...base, + allowConcurrentSyncOperations: true, + syncIn: async () => ({ operations: [] }), + syncOut: async () => ({ operations: [] }), + }; + const client = createCommandManagedRuntimeClient({ + runner: declaredNative, + commandCwd: "/", + timeoutMs: 1, + }); + expect(client.allowConcurrentSyncOperations).toBe(true); + }); + + it("base64 fallback ignores a runner opt-in without both sync verbs", () => { + // A runner that sets the opt-in but exposes only one sync verb still uses + // the fallback, which permits concurrency independent of the runner flag. + const base = makeSpawnRunner().runner; + const onlyIn: CommandManagedRuntimeRunner = { + ...base, + allowConcurrentSyncOperations: true, + syncIn: async () => ({ operations: [] }), + }; + const client = createCommandManagedRuntimeClient({ runner: onlyIn, commandCwd: "/", timeoutMs: 1 }); + expect(client.allowConcurrentSyncOperations).toBe(true); + }); + it("test_client_syncIn_delegates_to_native_runner_with_zero_execute_calls", async () => { // With a native runner, `client.syncIn` forwards `files` + `postUploadCommands` // to the runner and issues ZERO `execute` round-trips (the provider owns the diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 32e459a21fb..9c39ed3d069 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -19,6 +19,15 @@ import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress. import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js"; export interface CommandManagedRuntimeRunner { + /** + * True when the provider verified the concurrent-sync opt-in. A native runner + * carries the value from the effective capability snapshot + * (`concurrentSyncOperations`). The client copies it onto the prepared sync + * client only on the native path; the base64 fallback ignores it and always + * permits concurrency. The default is false, so an undeclared native provider + * never permits concurrent sync operations. + */ + allowConcurrentSyncOperations?: boolean; /** * True only when `execute({ stdin })` can surface useful in-flight progress * for a single stdin-backed command. Provider-backed sandbox runners usually @@ -446,6 +455,13 @@ export function createCommandManagedRuntimeClient(input: { const nativeSyncIn = input.runner.syncIn; const nativeSyncOut = input.runner.syncOut; const hasNativeBoth = Boolean(nativeSyncIn && nativeSyncOut); + // The base64 fallback always permits concurrent sync operations. A native + // runner permits them only when the provider verified the opt-in; an + // undeclared native provider keeps concurrency off. One flag serves both sync + // directions. + client.allowConcurrentSyncOperations = hasNativeBoth + ? input.runner.allowConcurrentSyncOperations === true + : true; client.syncIn = async (operations) => { assertPostUploadCommandsConfined(operations); if (hasNativeBoth) { diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index dcac81f41b2..9cb50e7dc02 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -115,6 +115,7 @@ export interface EffectiveSandboxCapabilities { readonly persistentProcessSessions: boolean; readonly independentControlCommands: boolean; readonly incrementalSessionOutput: boolean; + readonly concurrentSyncOperations: boolean; } export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { @@ -254,6 +255,7 @@ function parseEffectiveSandboxCapabilities(value: unknown): EffectiveSandboxCapa persistentProcessSessions: parsed.persistentProcessSessions === true, independentControlCommands: parsed.independentControlCommands === true, incrementalSessionOutput: parsed.incrementalSessionOutput === true, + concurrentSyncOperations: parsed.concurrentSyncOperations === true, }; } diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index e736385609d..6cb30ba1ed3 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -11,6 +11,7 @@ import { assertSyncOperationsConfined, mirrorDirectory, prepareSandboxManagedRuntime, + type SandboxManagedRuntimeAsset, type SandboxManagedRuntimeClient, type SandboxSyncOperation, type SandboxSyncResult, @@ -19,6 +20,7 @@ import { prepareCommandManagedRuntime, type CommandManagedRuntimeRunner, } from "./command-managed-runtime.js"; +import { SYNC_OPERATION_CONCURRENCY_LIMIT } from "./sync-operation-schedule.js"; import { createRuntimeSpanRunner, getActiveStepContext, @@ -438,6 +440,74 @@ describe("sandbox managed runtime", () => { expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing sandbox workspace"); }); + it.each(["workspace", "git-workspace"])( + "rejects an asset key that collides with the reserved %s archive name", + async (reservedKey) => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-asset-key-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + const localAssetsDir = path.join(rootDir, "local-assets"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(localAssetsDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace\n", "utf8"); + + const client = makeFilesystemClient(); + await expect( + prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + assets: [{ key: reservedKey, localDir: localAssetsDir }], + }), + ).rejects.toThrow(/collides with a reserved runtime archive name/); + + // The reserved-key guard fails before any workspace or asset archive is + // built, so nothing lands in the remote workspace directory. + await expect(readdir(remoteWorkspaceDir)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + + it.each(["skills/nested", "skills\\nested", "..", "../escape"])( + "rejects an asset key that is not a simple path segment: %s", + async (unsafeKey) => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-asset-key-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + const localAssetsDir = path.join(rootDir, "local-assets"); + await mkdir(localWorkspaceDir, { recursive: true }); + await mkdir(localAssetsDir, { recursive: true }); + await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace\n", "utf8"); + + const client = makeFilesystemClient(); + await expect( + prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + assets: [{ key: unsafeKey, localDir: localAssetsDir }], + }), + ).rejects.toThrow(/is not a simple path segment/); + }, + ); + it("syncs git-backed workspaces through a shallow standalone clone and keeps .git out of archives", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-git-")); cleanupDirs.push(rootDir); @@ -1507,6 +1577,129 @@ describe("sandbox managed runtime", () => { expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir); }); + it("the workspace wipe command preserves in-flight sync scratch tarballs (.paperclip-upload-*)", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-scratch-shape-")); + cleanupDirs.push(rootDir); + const sourceRepoDir = path.join(rootDir, "source-repo"); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(sourceRepoDir, { recursive: true }); + await git(sourceRepoDir, ["init"]); + await git(sourceRepoDir, ["checkout", "-b", "main"]); + await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]); + await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(sourceRepoDir, "tracked.txt"), "tracked\n", "utf8"); + await git(sourceRepoDir, ["add", "tracked.txt"]); + await git(sourceRepoDir, ["commit", "-m", "base"]); + await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); + + 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 () => [], + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + }; + const captured: SandboxSyncOperation[] = []; + attachNativeRecordingSyncIn(client, captured); + + await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + }); + + // The wipe `find` runs before the extract. It must preserve the daytona + // scratch prefix so a concurrent referenced-project upload survives the wipe. + const wipeCommand = captured[0].postUploadCommands![0].command; + expect(wipeCommand).toContain("find "); + expect(wipeCommand).toContain("! -name '.paperclip-upload-*'"); + }); + + it("the workspace wipe keeps an in-flight scratch tarball at the root but removes a stale sibling", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-scratch-race-")); + cleanupDirs.push(rootDir); + const sourceRepoDir = path.join(rootDir, "source-repo"); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(sourceRepoDir, { recursive: true }); + await git(sourceRepoDir, ["init"]); + await git(sourceRepoDir, ["checkout", "-b", "main"]); + await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]); + await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]); + await writeFile(path.join(sourceRepoDir, "tracked.txt"), "tracked\n", "utf8"); + await git(sourceRepoDir, ["add", "tracked.txt"]); + await git(sourceRepoDir, ["commit", "-m", "base"]); + await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); + // Pre-seed the sandbox root. `.paperclip-upload-test.tar` simulates a + // concurrent referenced-project scratch tarball in flight; `stale-junk.txt` + // is an unrelated child that the wipe must remove. + await mkdir(remoteWorkspaceDir, { recursive: true }); + await writeFile(path.join(remoteWorkspaceDir, ".paperclip-upload-test.tar"), "scratch\n", "utf8"); + await writeFile(path.join(remoteWorkspaceDir, "stale-junk.txt"), "junk\n", "utf8"); + + 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 () => [], + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + }; + const captured: SandboxSyncOperation[] = []; + attachNativeRecordingSyncIn(client, captured); + + await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "test-adapter", + client, + workspaceLocalDir: localWorkspaceDir, + }); + + // The real `find` wipe ran through `sh -c`. The scratch tarball survived and + // the unrelated sibling did not. + await expect( + readFile(path.join(remoteWorkspaceDir, ".paperclip-upload-test.tar"), "utf8"), + ).resolves.toBe("scratch\n"); + await expect( + readFile(path.join(remoteWorkspaceDir, "stale-junk.txt"), "utf8"), + ).rejects.toThrow(); + }); + it("issues one merged syncIn operation for a git-backed workspace stage-sync with two ordered extract commands", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-merged-git-")); cleanupDirs.push(rootDir); @@ -2053,14 +2246,17 @@ describe("sandbox managed runtime", () => { runtimeSpan, }); - expect(openedSpans).toEqual(["snapshot.git", "snapshot.baseline", "pack"]); + // The workspace stage task opens its own `stage.workspace` span, and the + // host tarball build opens the `pack` span inside it. The two pre-task + // sub-steps stay ahead of the task. + expect(openedSpans).toEqual(["snapshot.git", "snapshot.baseline", "stage.workspace", "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 () => { + it("nests the host pack span under the stage.workspace task span", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-nest-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); @@ -2114,13 +2310,16 @@ describe("sandbox managed runtime", () => { ); const stageSpan = spans.find((span) => span.name === "stage.sync"); + const workspaceSpan = spans.find((span) => span.name === "stage.workspace"); const packSpan = spans.find((span) => span.name === "pack"); expect(stageSpan).toBeDefined(); + expect(workspaceSpan).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"); + // The workspace stage task opens its own `stage.workspace` span under + // `stage.sync`, and the `pack` span nests under `stage.workspace`. + expect(workspaceSpan!.parentName).toBe("stage.sync"); + expect(packSpan!.parentName).toBe("stage.workspace"); // The two pre-`pack` staging sub-steps nest under `stage.sync` the same way, // so the previously hidden gap at the head of the step is now attributed. @@ -2132,3 +2331,872 @@ describe("sandbox managed runtime", () => { } }); }); + +// A deferred promise a test resolves or rejects by hand. +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} + +function defer(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function settleTick(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// A span recorder that captures each opened span name and the set of spans that +// are open right now. `opened` records every span in open order. `openNow` holds +// the names of the spans that started but did not end yet, so a test proves two +// concurrent tasks keep their spans open at the same time. +function makeSpanRecorder(): { + runtimeSpan: RuntimeSpanRunner; + opened: string[]; + openNow: Set; +} { + const opened: string[] = []; + const openNow = new Set(); + const runtimeSpan: RuntimeSpanRunner = async (name, work) => { + opened.push(name); + openNow.add(name); + try { + return await work(); + } finally { + openNow.delete(name); + } + }; + return { runtimeSpan, opened, openNow }; +} + +// A controlled `syncIn` client. It labels each inbound operation, records the +// start and settle order, and lets a test hold one upload open, release it, or +// make it fail. One operation rides each `syncIn` call, so one call maps to one +// label. This exercises the inbound coordinator's schedule, bound, failure +// semantics, and startup barrier with deferred-promise fakes. +interface SyncControl { + started: string[]; + settled: string[]; + waitForStart(label: string): Promise; + hold(label: string): void; + release(label: string): void; + failWith(label: string, error: Error): void; +} + +function labelOfOperation(operation: SandboxSyncOperation): string { + const bases = operation.files.map((mapping) => path.posix.basename(mapping.targetPath)); + if (bases.some((base) => base === "workspace-upload.tar" || base === "git-workspace-upload.tar")) { + return "workspace"; + } + const assetBase = bases.find((base) => base.endsWith("-upload.tar")); + if (assetBase) { + return assetBase.slice(0, -"-upload.tar".length); + } + const projectBase = bases.find((base) => base.startsWith("project-")); + if (projectBase) { + return projectBase; + } + return bases[0] ?? operation.operationId; +} + +function makeControlledSyncClient(options: { concurrent: boolean }): { + client: SandboxManagedRuntimeClient; + control: SyncControl; +} { + const started: string[] = []; + const settled: string[] = []; + const gates = new Map>(); + const failures = new Map(); + const startWaiters = new Map void>>(); + + const control: SyncControl = { + started, + settled, + waitForStart(label) { + if (started.includes(label)) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const waiters = startWaiters.get(label) ?? []; + waiters.push(resolve); + startWaiters.set(label, waiters); + }); + }, + hold(label) { + if (!gates.has(label)) { + gates.set(label, defer()); + } + }, + release(label) { + gates.get(label)?.resolve(); + }, + failWith(label, error) { + failures.set(label, error); + gates.get(label)?.resolve(); + }, + }; + + const noop = async (): Promise => {}; + const client: SandboxManagedRuntimeClient = { + makeDir: noop, + writeFile: noop, + readFile: async () => Buffer.alloc(0), + listFiles: async () => [], + remove: noop, + run: noop, + allowConcurrentSyncOperations: options.concurrent, + syncIn: async (operations) => { + const operation = operations[0]!; + const label = labelOfOperation(operation); + started.push(label); + const waiters = startWaiters.get(label) ?? []; + startWaiters.delete(label); + for (const waiter of waiters) { + waiter(); + } + try { + const gate = gates.get(label); + if (gate) { + await gate.promise; + } + const failure = failures.get(label); + if (failure) { + throw failure; + } + return { + operations: [{ + operationId: operation.operationId, + filesTransferred: operation.files.length, + bytesTransferred: 0, + }], + }; + } finally { + settled.push(label); + } + }, + }; + + return { client, control }; +} + +describe("sandbox managed runtime inbound coordinator", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + function makeSpec(remoteCwd: string) { + return { + transport: "sandbox" as const, + provider: "test", + sandboxId: "sandbox-1", + remoteCwd, + timeoutMs: 30_000, + apiKey: null, + }; + } + + // Create a temp root with one workspace directory and any named asset/project + // directories. Each directory carries one file, so the host tar step has bytes. + async function makeInboundDirs(names: string[]): Promise<{ + rootDir: string; + workspaceDir: string; + dirOf: (name: string) => string; + }> { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-inbound-coordinator-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + await writeFile(path.join(workspaceDir, "file.txt"), "workspace\n", "utf8"); + const dirs = new Map(); + for (const name of names) { + const dir = path.join(rootDir, name); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "file.txt"), `${name}\n`, "utf8"); + dirs.set(name, dir); + } + return { rootDir, workspaceDir, dirOf: (name) => dirs.get(name)! }; + } + + // Resolve true when the label started within the window, false on timeout. + async function startedWithin(control: SyncControl, label: string, ms: number): Promise { + return Promise.race([ + control.waitForStart(label).then(() => true), + settleTick(ms).then(() => false), + ]); + } + + it("with concurrency permitted, the home asset upload starts while the workspace upload is held open", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["home"]); + const { client, control } = makeControlledSyncClient({ concurrent: true }); + control.hold("workspace"); + + const prepared = prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [{ key: "home", localDir: dirOf("home") }], + }); + prepared.catch(() => undefined); + + // The workspace upload is open and held. The home asset upload still starts, + // so the coordinator runs the two operations concurrently. + await control.waitForStart("workspace"); + expect(await startedWithin(control, "home", 4000)).toBe(true); + expect(control.settled).not.toContain("workspace"); + + control.release("workspace"); + await prepared; + expect(control.settled).toContain("home"); + }); + + it("with five operations and the bound of 4, the fifth operation does not start while four stay open", async () => { + expect(SYNC_OPERATION_CONCURRENCY_LIMIT).toBe(4); + const assetKeys = Array.from( + { length: SYNC_OPERATION_CONCURRENCY_LIMIT + 1 }, + (_unused, index) => `asset-${index}`, + ); + const { workspaceDir, dirOf } = await makeInboundDirs(assetKeys); + const { client, control } = makeControlledSyncClient({ concurrent: true }); + for (const key of assetKeys) { + control.hold(key); + } + + const prepared = prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + syncWorkspace: false, + workspaceLocalDir: workspaceDir, + assets: assetKeys.map((key) => ({ key, localDir: dirOf(key) })), + }); + prepared.catch(() => undefined); + + const firstFour = assetKeys.slice(0, SYNC_OPERATION_CONCURRENCY_LIMIT); + const fifth = assetKeys[SYNC_OPERATION_CONCURRENCY_LIMIT]!; + for (const key of firstFour) { + await control.waitForStart(key); + } + // The bound holds the fifth operation while four stay open. + expect(await startedWithin(control, fifth, 300)).toBe(false); + expect(control.started.slice().sort()).toEqual(firstFour.slice().sort()); + + // One release frees one slot, so the fifth operation starts. + control.release(firstFour[0]!); + expect(await startedWithin(control, fifth, 4000)).toBe(true); + + for (const key of assetKeys) { + control.release(key); + } + await prepared; + expect(control.settled.slice().sort()).toEqual(assetKeys.slice().sort()); + }); + + it("holds one upload open after another upload fails, and returns only after both settle", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["asset-a", "asset-b"]); + const { client, control } = makeControlledSyncClient({ concurrent: true }); + control.hold("asset-b"); + + let coordinatorSettled = false; + const prepared = prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + syncWorkspace: false, + workspaceLocalDir: workspaceDir, + assets: [ + { key: "asset-a", localDir: dirOf("asset-a") }, + { key: "asset-b", localDir: dirOf("asset-b") }, + ], + }); + const done = prepared.then( + () => { coordinatorSettled = true; }, + () => { coordinatorSettled = true; }, + ); + + control.failWith("asset-a", new Error("asset-a-fail")); + await control.waitForStart("asset-b"); + await settleTick(100); + + // The first upload already failed. The second upload is still open, so the + // coordinator must not return yet. + expect(coordinatorSettled).toBe(false); + expect(control.settled).toContain("asset-a"); + expect(control.settled).not.toContain("asset-b"); + + control.release("asset-b"); + await done; + expect(coordinatorSettled).toBe(true); + expect(control.settled).toEqual(expect.arrayContaining(["asset-a", "asset-b"])); + }); + + it("records a referenced-project failure as nonfatal and finishes the other referenced-project uploads", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["good", "bad"]); + const { client, control } = makeControlledSyncClient({ concurrent: true }); + control.failWith("project-bad", new Error("bad-upload")); + + const prepared = await prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + syncWorkspace: false, + workspaceLocalDir: workspaceDir, + additionalSources: [ + { localPath: dirOf("good"), projectId: "good" }, + { localPath: dirOf("bad"), projectId: "bad" }, + ], + }); + + // The healthy project synced; the failed project is a recorded, nonfatal + // outcome, so the coordinator resolved. + expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["good"]); + expect(prepared.additionalSourceFailures.map((failure) => failure.projectId)).toEqual(["bad"]); + expect(prepared.additionalSourceFailures[0]!.error).toContain("bad-upload"); + expect(control.settled).toContain("project-good"); + }); + + it("raises an asset failure as fatal after the barrier", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["asset-good", "asset-bad"]); + const { client, control } = makeControlledSyncClient({ concurrent: true }); + control.failWith("asset-bad", new Error("asset-bad-fail")); + + await expect( + prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + syncWorkspace: false, + workspaceLocalDir: workspaceDir, + assets: [ + { key: "asset-good", localDir: dirOf("asset-good") }, + { key: "asset-bad", localDir: dirOf("asset-bad") }, + ], + }), + ).rejects.toThrow("asset-bad-fail"); + + // The barrier still settles the healthy asset before the coordinator raises. + expect(control.settled).toContain("asset-good"); + }); + + it("raises the earlier required failure when the workspace and an asset both fail", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["asset-a"]); + const { client, control } = makeControlledSyncClient({ concurrent: true }); + control.failWith("workspace", new Error("workspace-fail")); + control.failWith("asset-a", new Error("asset-a-fail")); + + // The workspace comes before the asset in stable operation order, so the + // coordinator raises the workspace failure. + await expect( + prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [{ key: "asset-a", localDir: dirOf("asset-a") }], + }), + ).rejects.toThrow("workspace-fail"); + }); + + it("with concurrency forbidden, keeps the serial schedule in the current order", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["asset-a"]); + const { client, control } = makeControlledSyncClient({ concurrent: false }); + control.hold("workspace"); + + const prepared = prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [{ key: "asset-a", localDir: dirOf("asset-a") }], + }); + prepared.catch(() => undefined); + + // The workspace runs first and is held open. Serial mode runs one operation + // at a time, so the asset upload does not start until the workspace settles. + await control.waitForStart("workspace"); + expect(await startedWithin(control, "asset-a", 300)).toBe(false); + expect(control.started).toEqual(["workspace"]); + + control.release("workspace"); + await control.waitForStart("asset-a"); + await prepared; + // The serial order stays workspace first, then the asset. + expect(control.started).toEqual(["workspace", "asset-a"]); + }); + + it("opens one named span per inbound task: stage.workspace, stage.asset., stage.project.", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["home", "proj"]); + const { client } = makeControlledSyncClient({ concurrent: false }); + const { runtimeSpan, opened } = makeSpanRecorder(); + + await prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [{ key: "home", localDir: dirOf("home") }], + additionalSources: [{ localPath: dirOf("proj"), projectId: "proj-1" }], + runtimeSpan, + }); + + // Each inbound task carries its own named span. The workspace task, the home + // asset task, and the referenced-project task each open one. + expect(opened).toContain("stage.workspace"); + expect(opened).toContain("stage.asset.home"); + expect(opened).toContain("stage.project.proj-1"); + }); + + it("with concurrency permitted, the workspace and asset inbound task spans overlap in time", async () => { + const { workspaceDir, dirOf } = await makeInboundDirs(["home"]); + const { client, control } = makeControlledSyncClient({ concurrent: true }); + const { runtimeSpan, openNow } = makeSpanRecorder(); + control.hold("workspace"); + control.hold("home"); + + const prepared = prepareSandboxManagedRuntime({ + spec: makeSpec("/remote/cwd"), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [{ key: "home", localDir: dirOf("home") }], + runtimeSpan, + }); + prepared.catch(() => undefined); + + // Both uploads are held open at their transfer. Each task span opens before + // its transfer and stays open while the transfer is held, so the two spans + // are open at the same time. + await control.waitForStart("workspace"); + await control.waitForStart("home"); + expect(openNow.has("stage.workspace")).toBe(true); + expect(openNow.has("stage.asset.home")).toBe(true); + + control.release("workspace"); + control.release("home"); + await prepared; + }); +}); + +// A controlled outbound restore. It reuses the deferred-promise fakes. The +// native `syncOut` copies the sandbox workspace back into the restore temp +// directory, and a gate holds the workspace restore open. Each asset carries a +// controlled `restore` callback the test can hold, release, or make fail. One +// label rides the workspace restore and one label rides each asset restore, so +// a test can watch the outbound coordinator schedule, bound, failure semantics, +// and teardown barrier. +interface OutboundControl { + started: string[]; + settled: string[]; + restoreTempDirs: Map; + waitForStart(label: string): Promise; + hold(label: string): void; + release(label: string): void; + failWith(label: string, error: Error): void; +} + +function makeOutboundControl(): { + control: OutboundControl; + gate: (label: string, run: () => Promise) => Promise; +} { + const started: string[] = []; + const settled: string[] = []; + const restoreTempDirs = new Map(); + const gates = new Map>(); + const failures = new Map(); + const startWaiters = new Map void>>(); + + const control: OutboundControl = { + started, + settled, + restoreTempDirs, + waitForStart(label) { + if (started.includes(label)) { + return Promise.resolve(); + } + return new Promise((resolve) => { + const waiters = startWaiters.get(label) ?? []; + waiters.push(resolve); + startWaiters.set(label, waiters); + }); + }, + hold(label) { + if (!gates.has(label)) { + gates.set(label, defer()); + } + }, + release(label) { + gates.get(label)?.resolve(); + }, + failWith(label, error) { + failures.set(label, error); + gates.get(label)?.resolve(); + }, + }; + + // Record the task start, notify start waiters, wait for the gate, raise a set + // failure, then run the task body and record the settle. A gate that a test + // never holds resolves at once, so an unheld task runs straight through. + async function gate(label: string, run: () => Promise): Promise { + started.push(label); + const waiters = startWaiters.get(label) ?? []; + startWaiters.delete(label); + for (const waiter of waiters) { + waiter(); + } + try { + const held = gates.get(label); + if (held) { + await held.promise; + } + const failure = failures.get(label); + if (failure) { + throw failure; + } + return await run(); + } finally { + settled.push(label); + } + } + + return { control, gate }; +} + +// A native filesystem client whose `syncOut` copies the sandbox workspace back +// through the gate. The inbound prepare step uses the base64-tar fallback +// `syncIn`. The client opts into concurrency by the flag. +function makeGatedOutboundClient( + concurrent: boolean, + gate: (label: string, run: () => Promise) => Promise, +): 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(); + }, + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + allowConcurrentSyncOperations: concurrent, + syncOut: async (operations) => + gate("workspace", async () => { + for (const operation of operations) { + for (const mapping of operation.files) { + if (mapping.kind === "directory") { + await mirrorDirectory(mapping.sourcePath, mapping.targetPath); + } else { + await mkdir(path.dirname(mapping.targetPath), { recursive: true }); + await writeFile(mapping.targetPath, await readFile(mapping.sourcePath)); + } + } + } + return { + operations: operations.map((operation) => ({ + operationId: operation.operationId, + filesTransferred: operation.files.length, + bytesTransferred: 0, + })), + }; + }), + }; + attachFallbackSyncIn(client); + return client; +} + +// Build one asset with a controlled `restore`. The restore records its own +// temp directory, then runs through the gate. The temp directory proves that +// two concurrent restore tasks keep separate scratch state. +function makeControlledAsset( + key: string, + localDir: string, + control: OutboundControl, + gate: (label: string, run: () => Promise) => Promise, +): SandboxManagedRuntimeAsset { + return { + key, + localDir, + restore: async (ctx) => { + control.restoreTempDirs.set(key, ctx.tempDir); + await gate(key, async () => { + if (ctx.tempDir) { + await writeFile(path.join(ctx.tempDir, `scratch-${key}.txt`), key, "utf8"); + } + }); + }, + }; +} + +describe("sandbox managed runtime outbound coordinator", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + function makeSpec(remoteCwd: string) { + return { + transport: "sandbox" as const, + provider: "test", + sandboxId: "sandbox-1", + remoteCwd, + timeoutMs: 30_000, + apiKey: null, + }; + } + + // Create a temp root with one workspace directory and any named asset + // directories. Each directory carries one file, so the host tar step has + // bytes and the merge has content. + async function makeOutboundDirs(names: string[]): Promise<{ + rootDir: string; + workspaceDir: string; + remoteWorkspaceDir: string; + dirOf: (name: string) => string; + }> { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-outbound-coordinator-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + await mkdir(workspaceDir, { recursive: true }); + await writeFile(path.join(workspaceDir, "file.txt"), "workspace\n", "utf8"); + const dirs = new Map(); + for (const name of names) { + const dir = path.join(rootDir, name); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "file.txt"), `${name}\n`, "utf8"); + dirs.set(name, dir); + } + return { rootDir, workspaceDir, remoteWorkspaceDir, dirOf: (name) => dirs.get(name)! }; + } + + // Resolve true when the label started within the window, false on timeout. + async function startedWithin(control: OutboundControl, label: string, ms: number): Promise { + return Promise.race([ + control.waitForStart(label).then(() => true), + settleTick(ms).then(() => false), + ]); + } + + it("with concurrency permitted, an asset restore starts while the workspace restore is held open", async () => { + const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home"]); + const { control, gate } = makeOutboundControl(); + const client = makeGatedOutboundClient(true, gate); + control.hold("workspace"); + + const prepared = await prepareSandboxManagedRuntime({ + spec: makeSpec(remoteWorkspaceDir), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [makeControlledAsset("home", dirOf("home"), control, gate)], + }); + + const restore = prepared.restoreWorkspace(); + restore.catch(() => undefined); + + // The workspace restore is open and held. The home asset restore still + // starts, so the coordinator runs the two tasks concurrently. + await control.waitForStart("workspace"); + expect(await startedWithin(control, "home", 4000)).toBe(true); + expect(control.settled).not.toContain("workspace"); + + control.release("workspace"); + await restore; + expect(control.settled).toContain("home"); + expect(control.settled).toContain("workspace"); + }); + + it("holds one restore open after another restore fails, and returns only after both settle", async () => { + const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["asset-a", "asset-b"]); + const { control, gate } = makeOutboundControl(); + const client = makeGatedOutboundClient(true, gate); + + const prepared = await prepareSandboxManagedRuntime({ + spec: makeSpec(remoteWorkspaceDir), + adapterKey: "codex", + client, + syncWorkspace: false, + workspaceLocalDir: workspaceDir, + assets: [ + makeControlledAsset("asset-a", dirOf("asset-a"), control, gate), + makeControlledAsset("asset-b", dirOf("asset-b"), control, gate), + ], + }); + + control.hold("asset-b"); + control.failWith("asset-a", new Error("asset-a-fail")); + + let coordinatorSettled = false; + const done = prepared.restoreWorkspace().then( + () => { coordinatorSettled = true; }, + () => { coordinatorSettled = true; }, + ); + + await control.waitForStart("asset-b"); + await settleTick(100); + + // The first restore already failed. The second restore is still open, so + // the coordinator must not return yet. + expect(coordinatorSettled).toBe(false); + expect(control.settled).toContain("asset-a"); + expect(control.settled).not.toContain("asset-b"); + + control.release("asset-b"); + await done; + expect(coordinatorSettled).toBe(true); + expect(control.settled).toEqual(expect.arrayContaining(["asset-a", "asset-b"])); + }); + + it("with concurrency forbidden, keeps the serial restore schedule in the current order", async () => { + const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["asset-a"]); + const { control, gate } = makeOutboundControl(); + const client = makeGatedOutboundClient(false, gate); + control.hold("workspace"); + + const prepared = await prepareSandboxManagedRuntime({ + spec: makeSpec(remoteWorkspaceDir), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [makeControlledAsset("asset-a", dirOf("asset-a"), control, gate)], + }); + + const restore = prepared.restoreWorkspace(); + restore.catch(() => undefined); + + // The workspace restore runs first and is held open. Serial mode runs one + // task at a time, so the asset restore does not start until the workspace + // restore settles. + await control.waitForStart("workspace"); + expect(await startedWithin(control, "asset-a", 300)).toBe(false); + expect(control.started).toEqual(["workspace"]); + + control.release("workspace"); + await control.waitForStart("asset-a"); + await restore; + // The serial order stays the workspace restore first, then the asset. + expect(control.started).toEqual(["workspace", "asset-a"]); + }); + + it("with concurrency permitted, two concurrent restore tasks use separate temporary state", async () => { + const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["asset-a", "asset-b"]); + const { control, gate } = makeOutboundControl(); + const client = makeGatedOutboundClient(true, gate); + + const prepared = await prepareSandboxManagedRuntime({ + spec: makeSpec(remoteWorkspaceDir), + adapterKey: "codex", + client, + syncWorkspace: false, + workspaceLocalDir: workspaceDir, + assets: [ + makeControlledAsset("asset-a", dirOf("asset-a"), control, gate), + makeControlledAsset("asset-b", dirOf("asset-b"), control, gate), + ], + }); + + control.hold("asset-a"); + control.hold("asset-b"); + const restore = prepared.restoreWorkspace(); + + // Both restore tasks start together under the bound. Each task carries its + // own restore temp directory, so the two directories differ. + await control.waitForStart("asset-a"); + await control.waitForStart("asset-b"); + const tempA = control.restoreTempDirs.get("asset-a"); + const tempB = control.restoreTempDirs.get("asset-b"); + expect(tempA).toBeTruthy(); + expect(tempB).toBeTruthy(); + expect(tempA).not.toBe(tempB); + expect(tempA!).toContain("paperclip-sandbox-restore-"); + expect(tempB!).toContain("paperclip-sandbox-restore-"); + + control.release("asset-a"); + control.release("asset-b"); + await restore; + expect(control.settled).toEqual(expect.arrayContaining(["asset-a", "asset-b"])); + }); + + it("opens one named span per outbound restore task: restore.workspace, restore.asset.", async () => { + const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home"]); + const { control, gate } = makeOutboundControl(); + const client = makeGatedOutboundClient(true, gate); + const { runtimeSpan, opened } = makeSpanRecorder(); + + const prepared = await prepareSandboxManagedRuntime({ + spec: makeSpec(remoteWorkspaceDir), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [makeControlledAsset("home", dirOf("home"), control, gate)], + runtimeSpan, + }); + + await prepared.restoreWorkspace(); + + // Each outbound restore task carries its own named span. The workspace + // restore task and the home asset restore task each open one. + expect(opened).toContain("restore.workspace"); + expect(opened).toContain("restore.asset.home"); + }); + + it("with concurrency permitted, the workspace and asset restore task spans overlap in time", async () => { + const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home"]); + const { control, gate } = makeOutboundControl(); + const client = makeGatedOutboundClient(true, gate); + const { runtimeSpan, openNow } = makeSpanRecorder(); + + const prepared = await prepareSandboxManagedRuntime({ + spec: makeSpec(remoteWorkspaceDir), + adapterKey: "codex", + client, + workspaceLocalDir: workspaceDir, + assets: [makeControlledAsset("home", dirOf("home"), control, gate)], + runtimeSpan, + }); + + control.hold("workspace"); + control.hold("home"); + const restore = prepared.restoreWorkspace(); + restore.catch(() => undefined); + + // Both restore tasks are held open. Each restore task span opens before its + // work and stays open while the work is held, so the two spans are open at + // the same time. + await control.waitForStart("workspace"); + await control.waitForStart("home"); + expect(openNow.has("restore.workspace")).toBe(true); + expect(openNow.has("restore.asset.home")).toBe(true); + + control.release("workspace"); + control.release("home"); + await restore; + }); +}); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 3ded18487b1..3bee4f7851a 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -27,6 +27,11 @@ import { type RuntimeStatusSink, } from "./runtime-progress.js"; import { isRelativePathOrDescendant, shouldExcludePath } from "./exclude-patterns.js"; +import { + scheduleSyncOperations, + SYNC_OPERATION_CONCURRENCY_LIMIT, + type SyncOperationTask, +} from "./sync-operation-schedule.js"; import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js"; const execFile = promisify(execFileCallback); @@ -102,11 +107,18 @@ export interface SandboxManagedRuntimeAssetProvision { /** * Context passed to an asset's `restore` contribution during teardown. * `assetDir` is the asset's directory inside the sandbox and `readFile` reads - * a file back from the sandbox as raw bytes. + * a file back from the sandbox as raw bytes. `tempDir` is a host scratch + * directory that belongs to this restore task alone. The shared scheduler can + * run restore tasks at the same time, so a task must not share scratch space + * with another task. A restore that needs a host temporary file writes it under + * `tempDir`. The coordinator removes the directory after the task settles. The + * sandbox coordinator always sets `tempDir`. A serial runtime that never runs + * restore tasks at the same time can omit it. */ export interface SandboxManagedRuntimeAssetRestoreContext { assetDir: string; readFile: (remotePath: string) => Promise; + tempDir?: string; } export interface SandboxManagedRuntimeAsset { @@ -244,6 +256,15 @@ export interface SandboxManagedRuntimeClient { listFiles(remotePath: string): Promise; remove(remotePath: string): Promise; run(command: string, options: { timeoutMs: number }): Promise; + /** + * True when the orchestrator may run this client's sync operations + * concurrently. The base64 fallback always sets it true. A native provider + * takes the value from the verified `concurrentSyncOperations` opt-in; an + * undeclared native provider keeps it false. One flag serves both `syncIn` and + * `syncOut`. `createCommandManagedRuntimeClient` always sets it on a prepared + * client; it is optional here so a test mock can omit it. + */ + allowConcurrentSyncOperations?: boolean; /** * Optional native inbound transfer. Present only when the sandbox provider * advertises both `environmentSyncIn` and `environmentSyncOut`; otherwise the @@ -358,9 +379,18 @@ function buildWorkspaceTarExtractCommand(input: { remoteTar: string; wipeExceptNames: string[] | null; }): string { + // The wipe must also preserve any in-flight sync scratch tarball at the + // workspace root. A concurrent referenced-project upload stages a scratch + // tarball named `.paperclip-upload-.tar` there. Without this preserve + // term the wipe unlinks the in-flight tarball and the later extract fails. + // The static pattern must agree with the daytona scratch prefix + // `SCRATCH_PREFIX` in + // `packages/plugins/sandbox-providers/daytona/src/file-sync.ts:80`. + // The term is a static literal; `preserveFindArgs` shell-quotes it, so the + // shell passes it to `find -name` as a pattern (Security Conditions C1/C3). const wipe = input.wipeExceptNames ? ` && find ${shellQuote(input.workspaceRemoteDir)} -mindepth 1 -maxdepth 1 ` + - `${preserveFindArgs(input.wipeExceptNames)} -exec rm -rf -- {} +` + `${preserveFindArgs([...input.wipeExceptNames, ".paperclip-upload-*"])} -exec rm -rf -- {} +` : ""; return ( `mkdir -p ${shellQuote(input.workspaceRemoteDir)}${wipe} && ` + @@ -384,6 +414,29 @@ function buildUniqueStagingPath(input: { targetPath: string; suffix: string }): return `${input.targetPath}${input.suffix}.${randomUUID()}`; } +// The workspace stages under `/workspace-upload.tar` and, for a +// git-backed workspace, under `/git-workspace-upload.tar`. Each +// asset stages under `/-upload.tar`, so an asset key equal to +// one of these stems resolves to the same remote archive path. Reserve the stems. +const RESERVED_RUNTIME_ASSET_KEYS = new Set(["workspace", "git-workspace"]); + +// Reject an asset key before any path is built from it. An asset key becomes a +// remote directory (`/`), a remote archive name +// (`-upload.tar`), and a host temp file (`.tar`). A path separator or +// `..` in the key escapes those roots. A reserved stem makes the asset archive +// share a path with the workspace archive; under concurrent sync the asset task +// and the workspace task then write or upload the same archive at the same time, +// which fails extraction nondeterministically or puts asset bytes in the +// workspace. Fail closed on both cases. +function assertRuntimeAssetKeyIsSafe(key: string): void { + if (key.length === 0 || key.includes("/") || key.includes("\\") || key.includes("..")) { + throw new Error(`sandbox runtime asset key is not a simple path segment: ${key}`); + } + if (RESERVED_RUNTIME_ASSET_KEYS.has(key)) { + throw new Error(`sandbox runtime asset key collides with a reserved runtime archive name: ${key}`); + } +} + export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteExecutionSpec | null { const parsed = asObject(value); const transport = asString(parsed.transport).trim(); @@ -715,13 +768,24 @@ export async function prepareSandboxManagedRuntime(input: { const runtimeRootDir = path.posix.join(workspaceRemoteDir, ".paperclip-runtime", input.adapterKey); const syncWorkspace = input.syncWorkspace !== false; - // Wrap a host-side staging sub-step in its own span when the caller injects a - // runtime span runner. Mirrors the `pack` span below: the runner defaults to a - // no-op, so a caller with no injected runner keeps the current control flow, - // and a throwing runner never changes it (see `createRuntimeSpanRunner`). Each - // span parents under the `stage.sync` step, so the two pre-`pack` operations — - // the git enumeration and the baseline content-hash walk — stop showing up as - // a hidden gap at the head of the step. + // Reject any unsafe asset key before an archive path or an asset directory is + // built from it. This runs before the git snapshot work so a bad key fails fast. + for (const asset of input.assets ?? []) { + assertRuntimeAssetKeyIsSafe(asset.key); + } + + // Wrap a host-side staging sub-step or one scheduler task in its own span when + // the caller injects a runtime span runner. The runner defaults to a no-op, so + // a caller with no injected runner keeps the current control flow, and a + // throwing runner never changes it (see `createRuntimeSpanRunner`). The two + // pre-`pack` operations — the git enumeration and the baseline content-hash + // walk — parent under the `stage.sync` step, so they stop showing up as a + // hidden gap at the head of the step. Each inbound task (`stage.workspace`, + // `stage.asset.`, `stage.project.`) and each outbound restore task + // (`restore.workspace`, `restore.asset.`) opens its own span, so two + // concurrent tasks produce overlapping spans and the `pack` span nests under + // `stage.workspace`. The outbound spans parent under the run's `sandbox.syncBack` + // span, because the teardown runs the restore inside that span. const runStepSpan = (name: string, work: () => Promise): Promise => input.runtimeSpan ? input.runtimeSpan(name, work) : work(); @@ -851,167 +915,192 @@ export async function prepareSandboxManagedRuntime(input: { await upload.finish(params.progressBytes, params.progressBytes); }; + // Build the ordered inbound operation task list. Each task stages one inbound + // operation from start to end: it packs the bytes, confines the mappings, and + // uploads them. The shared scheduler starts the tasks. When the sync client + // permits concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT + // tasks active. When the client does not permit concurrency, the scheduler runs + // one task at a time in this order. The scheduler settles every started task + // before it returns, so no upload outlives the coordinator and ACP never starts + // before the barrier. + const inboundTasks: Array> = []; + // A required-flag list, one entry per task and index-aligned with inboundTasks. + // The workspace and each asset are required operations: a rejection is fatal + // after the barrier. Each referenced project is a nonfatal operation: its task + // records its own failure and never rejects. + const inboundTaskIsRequired: boolean[] = []; + if (syncWorkspace) { - // A git-backed workspace and a plain workspace both stage through ONE - // confined `syncIn` operation. A git-backed workspace carries TWO host tars — - // the git-history clone and the working-tree overlay — as two `file` mappings - // on the SAME operation, with their extract commands as ordered - // `postUploadCommands`. One operation shares one mkdir, one confine guard, one - // `uploadFiles`, and one rename exec, so the second `syncIn` round trip is - // removed. Build the whole merged file set and command list BEFORE the confine - // guard runs (inside `stageConfinedSyncIn`); never append a mapping after it. - const workspaceFiles: SandboxSyncFileMapping[] = []; - const workspacePostUploadCommands: SandboxPostUploadCommand[] = []; - let workspaceUploadBytes = 0; + inboundTaskIsRequired.push(true); + inboundTasks.push(() => + runStepSpan("stage.workspace", async () => { + // A git-backed workspace and a plain workspace both stage through ONE + // confined `syncIn` operation. A git-backed workspace carries TWO host tars — + // the git-history clone and the working-tree overlay — as two `file` mappings + // on the SAME operation, with their extract commands as ordered + // `postUploadCommands`. One operation shares one mkdir, one confine guard, one + // `uploadFiles`, and one rename exec, so the second `syncIn` round trip is + // removed. Build the whole merged file set and command list BEFORE the confine + // guard runs (inside `stageConfinedSyncIn`); never append a mapping after it. + const workspaceFiles: SandboxSyncFileMapping[] = []; + const workspacePostUploadCommands: SandboxPostUploadCommand[] = []; + let workspaceUploadBytes = 0; - // 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. - await runStepSpan("pack", 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) => { + // 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. + await runStepSpan("pack", 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: cloneDir, - archivePath: gitTarPath, - exclude: [".paperclip-runtime"], + localDir: workspaceArchiveDir, + archivePath: workspaceTarPath, + exclude: gitSnapshot ? undefined : workspaceArchiveExclude, }); + 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; }); - 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, + // One confined `syncIn` for the whole merged workspace file set. The confine + // guard covers every mapping BEFORE any bytes upload (fail-closed): a source + // or target escape in EITHER tar mapping stops the upload of both. + await stageConfinedSyncIn({ + files: workspaceFiles, + postUploadCommands: workspacePostUploadCommands, + sourceRoots: [tempDir], + targetRoots: [runtimeRootDir], + progressLabel: "workspace", + statusPhase: "config_sync", + progressBytes: workspaceUploadBytes, }); - } - await createTarballFromDirectory({ - localDir: workspaceArchiveDir, - archivePath: workspaceTarPath, - exclude: gitSnapshot ? undefined : workspaceArchiveExclude, - }); - 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 - // or target escape in EITHER tar mapping stops the upload of both. - await stageConfinedSyncIn({ - files: workspaceFiles, - postUploadCommands: workspacePostUploadCommands, - sourceRoots: [tempDir], - targetRoots: [runtimeRootDir], - progressLabel: "workspace", - statusPhase: "config_sync", - progressBytes: workspaceUploadBytes, - }); + }), + ); } for (const asset of input.assets ?? []) { - await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to sandbox"); - const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key); - const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`); - // Every asset — default OR custom-provisioned (e.g. an adapter credential - // merge) — rides one `syncIn` operation: the asset tar plus any staged - // helper files as `files` mappings, and the extract/merge command as the - // ordered post-upload command. There is no native-diversion gate; a - // custom-provisioned asset's bytes now ride native `uploadFiles` and its - // command runs as a provider-executed post-upload command. - const assetTarPath = path.join(tempDir, `${asset.key}.tar`); - await createTarballFromDirectory({ - localDir: asset.localDir, - archivePath: assetTarPath, - followSymlinks: asset.followSymlinks, - exclude: asset.exclude, - }); - const files: SandboxSyncFileMapping[] = [ - { sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file", access: "rw", writablePath: remoteAssetDir }, - ]; - // Stage provision helper files (e.g. the merge scripts) into the temp dir - // and map them alongside the asset tar so they ride the same native upload. - for (const stageFile of asset.provision?.stageFiles ?? []) { - const safeName = stageFile.name; - if (/[\\/]|\.\.(\.|$)/.test(safeName) || safeName === "..") { - throw new Error(`provision stageFile.name must be a simple basename, got: ${safeName}`); - } - const stageBytes = typeof stageFile.contents === "string" - ? Buffer.from(stageFile.contents) - : stageFile.contents; - const stageHostPath = path.join(tempDir, `${asset.key}.stage.${safeName}`); - await fs.writeFile(stageHostPath, stageBytes); - // A stage helper file (for example a merge script) is a read-only input - // that the provision command reads; the agent does not change it and does - // not keep it. So it is `access: "ro"` and never joins the writable set. - files.push({ - sourcePath: stageHostPath, - targetPath: path.posix.join(runtimeRootDir, safeName), - kind: "file", - access: "ro", - }); - } - const postUploadCommand = asset.provision?.postUploadCommand?.({ - assetTarPath: remoteAssetTar, - assetDir: remoteAssetDir, - runtimeRootDir, - }) ?? buildDefaultExtractRuntimeAssetCommand({ remoteAssetDir, remoteAssetTar }); - const assetTarSize = (await fs.stat(assetTarPath)).size; - await stageConfinedSyncIn({ - files, - postUploadCommands: [{ command: postUploadCommand }], - sourceRoots: [tempDir], - targetRoots: [runtimeRootDir], - progressLabel: asset.key, - statusPhase: "config_sync", - progressBytes: assetTarSize, - }); + inboundTaskIsRequired.push(true); + inboundTasks.push(() => + runStepSpan(`stage.asset.${asset.key}`, async () => { + await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing runtime assets to sandbox"); + const remoteAssetDir = path.posix.join(runtimeRootDir, asset.key); + const remoteAssetTar = path.posix.join(runtimeRootDir, `${asset.key}-upload.tar`); + // Every asset — default OR custom-provisioned (e.g. an adapter credential + // merge) — rides one `syncIn` operation: the asset tar plus any staged + // helper files as `files` mappings, and the extract/merge command as the + // ordered post-upload command. There is no native-diversion gate; a + // custom-provisioned asset's bytes now ride native `uploadFiles` and its + // command runs as a provider-executed post-upload command. + const assetTarPath = path.join(tempDir, `${asset.key}.tar`); + await createTarballFromDirectory({ + localDir: asset.localDir, + archivePath: assetTarPath, + followSymlinks: asset.followSymlinks, + exclude: asset.exclude, + }); + const files: SandboxSyncFileMapping[] = [ + { sourcePath: assetTarPath, targetPath: remoteAssetTar, kind: "file", access: "rw", writablePath: remoteAssetDir }, + ]; + // Stage provision helper files (e.g. the merge scripts) into the temp dir + // and map them alongside the asset tar so they ride the same native upload. + for (const stageFile of asset.provision?.stageFiles ?? []) { + const safeName = stageFile.name; + if (/[\\/]|\.\.(\.|$)/.test(safeName) || safeName === "..") { + throw new Error(`provision stageFile.name must be a simple basename, got: ${safeName}`); + } + const stageBytes = typeof stageFile.contents === "string" + ? Buffer.from(stageFile.contents) + : stageFile.contents; + const stageHostPath = path.join(tempDir, `${asset.key}.stage.${safeName}`); + await fs.writeFile(stageHostPath, stageBytes); + // A stage helper file (for example a merge script) is a read-only input + // that the provision command reads; the agent does not change it and does + // not keep it. So it is `access: "ro"` and never joins the writable set. + files.push({ + sourcePath: stageHostPath, + targetPath: path.posix.join(runtimeRootDir, safeName), + kind: "file", + access: "ro", + }); + } + const postUploadCommand = asset.provision?.postUploadCommand?.({ + assetTarPath: remoteAssetTar, + assetDir: remoteAssetDir, + runtimeRootDir, + }) ?? buildDefaultExtractRuntimeAssetCommand({ remoteAssetDir, remoteAssetTar }); + const assetTarSize = (await fs.stat(assetTarPath)).size; + await stageConfinedSyncIn({ + files, + postUploadCommands: [{ command: postUploadCommand }], + sourceRoots: [tempDir], + targetRoots: [runtimeRootDir], + progressLabel: asset.key, + statusPhase: "config_sync", + progressBytes: assetTarSize, + }); + }), + ); } // Stage each referenced (additional) project as a plain, read-only tree in @@ -1023,46 +1112,83 @@ export async function prepareSandboxManagedRuntime(input: { // project's confinement or sync failure logs a warning and is skipped, and // the run plus the other projects continue. Only a project that stages // successfully appears in `additionalSourceDirs`. - for (const source of input.additionalSources ?? []) { - const { localPath, projectId } = source; - const label = `project-${projectId}`; - try { - if (!path.posix.isAbsolute(localPath)) { - throw new Error(`additional source localPath is not an absolute path: ${localPath}`); - } - if ( - projectId.length === 0 || - projectId.includes("/") || - projectId.includes("\\") || - projectId.includes("..") - ) { - throw new Error(`additional source projectId is not a simple path segment: ${projectId}`); - } - const remoteProjectDir = path.posix.join(runtimeRootDir, label); - await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to sandbox"); - await stageConfinedSyncIn({ - files: [{ - sourcePath: localPath, - targetPath: remoteProjectDir, - kind: "directory", - exclude: additionalSourceExclude, - access: "ro", - }], - sourceRoots: [localPath], - targetRoots: [remoteProjectDir], - progressLabel: label, - statusPhase: "config_sync", - progressBytes: 0, - }); - additionalSourceDirs[projectId] = remoteProjectDir; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn( - `[paperclip] Failed to stage referenced project ${projectId}; skipping it. ${message}`, - ); - // Record the failure as a first-class per-project outcome so the run can count it in the - // requested-vs-synced accounting instead of losing it to a warning line. - additionalSourceFailures.push({ projectId, error: message }); + const additionalSourceList = input.additionalSources ?? []; + // Record each referenced-project failure in its input-order slot. Slot order + // keeps the failure list stable no matter the task completion order under + // concurrency. + const additionalFailureSlots: Array = + additionalSourceList.map(() => null); + additionalSourceList.forEach((source, sourceIndex) => { + inboundTaskIsRequired.push(false); + inboundTasks.push(() => + runStepSpan(`stage.project.${source.projectId}`, async () => { + const { localPath, projectId } = source; + const label = `project-${projectId}`; + try { + if (!path.posix.isAbsolute(localPath)) { + throw new Error(`additional source localPath is not an absolute path: ${localPath}`); + } + if ( + projectId.length === 0 || + projectId.includes("/") || + projectId.includes("\\") || + projectId.includes("..") + ) { + throw new Error(`additional source projectId is not a simple path segment: ${projectId}`); + } + const remoteProjectDir = path.posix.join(runtimeRootDir, label); + await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing referenced project to sandbox"); + await stageConfinedSyncIn({ + files: [{ + sourcePath: localPath, + targetPath: remoteProjectDir, + kind: "directory", + exclude: additionalSourceExclude, + access: "ro", + }], + sourceRoots: [localPath], + targetRoots: [remoteProjectDir], + progressLabel: label, + statusPhase: "config_sync", + progressBytes: 0, + }); + additionalSourceDirs[projectId] = remoteProjectDir; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Record the failure as a first-class per-project outcome so the run can count it in the + // requested-vs-synced accounting and surface the reason on the run result and the run log. + // The structured slot carries the reason, so no `console.warn` line is needed here. + additionalFailureSlots[sourceIndex] = { projectId, error: message }; + } + }), + ); + }); + + // Run every inbound operation through the shared scheduler. It starts the + // tasks under the client's concurrency permission and the bound, and it + // settles every started task before it returns. This is the startup barrier: + // the coordinator returns only after every upload settles. + const inboundResults = await scheduleSyncOperations( + inboundTasks, + input.client.allowConcurrentSyncOperations === true, + SYNC_OPERATION_CONCURRENCY_LIMIT, + ); + + // Collect the referenced-project failures in stable input order. + for (const failure of additionalFailureSlots) { + if (failure) { + additionalSourceFailures.push(failure); + } + } + + // Select a fatal failure in stable operation order. The workspace comes first, + // then each asset. A referenced-project task never rejects, so a nonfatal + // outcome never appears here. Raise the first required rejection, so two + // required failures raise the earlier one. + for (let index = 0; index < inboundResults.length; index += 1) { + const result = inboundResults[index]; + if (inboundTaskIsRequired[index] && result.status === "rejected") { + throw result.reason; } } }); @@ -1081,182 +1207,227 @@ export async function prepareSandboxManagedRuntime(input: { additionalSourceFailures, restoreWorkspace: async (onProgress?: RuntimeProgressSink) => { const restoreSink = onProgress ?? input.onProgress; - if (!syncWorkspace) { - for (const asset of input.assets ?? []) { - if (!asset.restore) continue; - await asset.restore({ - assetDir: path.posix.join(runtimeRootDir, asset.key), - readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)), - }); - } - return; - } - await withTempDir("paperclip-sandbox-restore-", async (tempDir) => { - let importedRef: string | null = null; - let importedHead: string | null = null; - let remoteWorkspaceStatus = "dirty"; - try { - if (gitSnapshot) { - await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from sandbox"); - importedRef = createImportedGitRef("sandbox"); - const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle"); - const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt"); - const exportRef = createRemoteGitExportRef("sandbox"); - const localBundlePath = path.join(tempDir, "git-delta.bundle"); - // Export the sandbox history and import it into the host workspace. - // The delta bundle assumes the host holds the bundle's boundary - // commit; when the host has been reset far enough that it does not, - // the import fails on a missing prerequisite. In that case re-export - // a full, self-contained bundle from the still-live sandbox rather - // than discard the completed run. - const exportAndImport = async (forceFullBundle: boolean): Promise => { - await input.client.run( - `sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({ - remoteDir: workspaceRemoteDir, - baseSha: gitSnapshot.headCommit, - exportRef, - bundlePath: remoteGitBundle, - statusPath: forceFullBundle ? undefined : remoteWorkspaceStatusPath, - forceFullBundle, - }))}`, - { timeoutMs: input.spec.timeoutMs }, - ); - const gitExport = makeTransferProgress( - restoreSink, - "Exporting git history", - "from", - undefined, - { sink: input.onRuntimeProgress, phase: "export" }, - ); - const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options); - const bundleBuffer = toBuffer(bundleBytes); - await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength); - await input.client.remove(remoteGitBundle).catch(() => undefined); - if (!forceFullBundle) { - remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath) - .then((bytes) => toBuffer(bytes).toString("utf8").trim()) - .catch(() => "dirty"); - remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty"; - await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined); + // Build the ordered outbound restore task list. Each task runs one + // restore from start to end: the workspace restore, or one asset restore. + // The shared scheduler starts the tasks. When the sync client permits + // concurrency, the scheduler keeps at most SYNC_OPERATION_CONCURRENCY_LIMIT + // tasks active. When the client does not permit concurrency, the scheduler + // runs one task at a time in this order. The scheduler settles every + // started task before it returns, so lease teardown never starts while an + // outbound restore task still writes host data. + const outboundTasks: Array> = []; + + // The workspace restore task runs only when the run syncs the workspace. + // The task exports the sandbox git history (git-backed workspace), reads + // the sandbox workspace back, and merges it into the host workspace root. + // The merge is the only outbound write inside the host workspace root. + // Every other task writes a disjoint host target: an asset restore writes + // its own store outside the workspace root. So the workspace task and the + // asset tasks share one parallel set. Keep any future asset that must write + // inside the host workspace root out of this set, and run it after the + // merge. Each task gets its own restore temp directory, so two concurrent + // tasks never share scratch state. + if (syncWorkspace) { + outboundTasks.push(() => + runStepSpan("restore.workspace", async () => { + await withTempDir("paperclip-sandbox-restore-", async (tempDir) => { + let importedRef: string | null = null; + let importedHead: string | null = null; + let remoteWorkspaceStatus = "dirty"; + try { + if (gitSnapshot) { + await emitRuntimeStatus(input.onRuntimeProgress, "export", "Exporting git changes from sandbox"); + importedRef = createImportedGitRef("sandbox"); + const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle"); + const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt"); + const exportRef = createRemoteGitExportRef("sandbox"); + const localBundlePath = path.join(tempDir, "git-delta.bundle"); + + // Export the sandbox history and import it into the host workspace. + // The delta bundle assumes the host holds the bundle's boundary + // commit; when the host has been reset far enough that it does not, + // the import fails on a missing prerequisite. In that case re-export + // a full, self-contained bundle from the still-live sandbox rather + // than discard the completed run. + const exportAndImport = async (forceFullBundle: boolean): Promise => { + await input.client.run( + `sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({ + remoteDir: workspaceRemoteDir, + baseSha: gitSnapshot.headCommit, + exportRef, + bundlePath: remoteGitBundle, + statusPath: forceFullBundle ? undefined : remoteWorkspaceStatusPath, + forceFullBundle, + }))}`, + { timeoutMs: input.spec.timeoutMs }, + ); + const gitExport = makeTransferProgress( + restoreSink, + "Exporting git history", + "from", + undefined, + { sink: input.onRuntimeProgress, phase: "export" }, + ); + const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options); + const bundleBuffer = toBuffer(bundleBytes); + await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength); + await input.client.remove(remoteGitBundle).catch(() => undefined); + if (!forceFullBundle) { + remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath) + .then((bytes) => toBuffer(bytes).toString("utf8").trim()) + .catch(() => "dirty"); + remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty"; + await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined); + } + await fs.writeFile(localBundlePath, bundleBuffer); + return fetchGitBundleIntoLocalRef({ + localDir: input.workspaceLocalDir, + bundlePath: localBundlePath, + exportRef, + importedRef: importedRef!, + baseSha: gitSnapshot.headCommit, + }); + }; + + try { + importedHead = await exportAndImport(false); + } catch (error) { + if (!isMissingGitPrerequisiteError(error)) throw error; + importedHead = await exportAndImport(true); + } + } + + await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox"); + const extractedDir = path.join(tempDir, "workspace"); + if (nativeSyncOut) { + // Native outbound: the provider materializes the sandbox workspace into + // a fresh host directory. It is a clean destroy-then-replace into a + // temp dir the orchestrator just created, so it maps exactly to a + // generic directory file mapping; the host-side baseline merge below is + // unchanged. + const operations: SandboxSyncOperation[] = [{ + operationId: nextSyncOperationId(), + files: [{ + sourcePath: workspaceRemoteDir, + targetPath: extractedDir, + kind: "directory", + exclude: restoreExclude, + }], + }]; + assertSyncOperationsConfined(operations, { + sourceRoots: [workspaceRemoteDir], + targetRoots: [extractedDir], + }); + await fs.mkdir(extractedDir, { recursive: true }); + const workspaceRestore = makeTransferProgress( + restoreSink, + "Restoring", + "from", + "workspace", + { sink: input.onRuntimeProgress, phase: "restore" }, + ); + await input.client.syncOut!(operations); + await workspaceRestore.finish(0, 0); + } else { + const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar"); + await input.client.run( + `sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({ + remoteDir: workspaceRemoteDir, + archivePath: remoteWorkspaceTar, + exclude: restoreExclude, + }))}`, + { timeoutMs: input.spec.timeoutMs }, + ); + const workspaceRestore = makeTransferProgress( + restoreSink, + "Restoring", + "from", + "workspace", + { sink: input.onRuntimeProgress, phase: "restore" }, + ); + const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options); + const archiveBuffer = toBuffer(archiveBytes); + await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength); + await input.client.remove(remoteWorkspaceTar).catch(() => undefined); + const localArchivePath = path.join(tempDir, "workspace.tar"); + await fs.writeFile(localArchivePath, archiveBuffer); + await extractTarballToDirectory({ + archivePath: localArchivePath, + localDir: extractedDir, + }); + } + const gitHeadToIntegrate = importedHead; + await mergeDirectoryWithBaseline({ + baseline: baselineSnapshot!, + sourceDir: extractedDir, + targetDir: input.workspaceLocalDir, + beforeApply: gitHeadToIntegrate + ? async () => { + await integrateImportedGitHead({ + localDir: input.workspaceLocalDir, + importedHead: gitHeadToIntegrate, + }); + } + : undefined, + afterApply: gitSnapshot + ? async () => { + await resetLocalGitIndexToHead({ + localDir: input.workspaceLocalDir, + checkWorkingTreeClean: remoteWorkspaceStatus === "clean", + }); + } + : undefined, + }); + } finally { + await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing sandbox workspace"); + if (importedRef) { + await deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef }); + } } - await fs.writeFile(localBundlePath, bundleBuffer); - return fetchGitBundleIntoLocalRef({ - localDir: input.workspaceLocalDir, - bundlePath: localBundlePath, - exportRef, - importedRef: importedRef!, - baseSha: gitSnapshot.headCommit, + }); + }), + ); + } + + // One restore task per asset that has a teardown contribution. Each task + // reads its asset back through `readFile` and writes a disjoint host + // target. Each task gets its own restore temp directory. + for (const asset of input.assets ?? []) { + if (!asset.restore) continue; + const assetRestore = asset.restore; + const assetKey = asset.key; + outboundTasks.push(() => + runStepSpan(`restore.asset.${assetKey}`, async () => { + await withTempDir("paperclip-sandbox-restore-", async (tempDir) => { + await assetRestore({ + assetDir: path.posix.join(runtimeRootDir, assetKey), + readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)), + tempDir, }); - }; - - try { - importedHead = await exportAndImport(false); - } catch (error) { - if (!isMissingGitPrerequisiteError(error)) throw error; - importedHead = await exportAndImport(true); - } - } - - await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox"); - const extractedDir = path.join(tempDir, "workspace"); - if (nativeSyncOut) { - // Native outbound: the provider materializes the sandbox workspace into - // a fresh host directory. It is a clean destroy-then-replace into a - // temp dir the orchestrator just created, so it maps exactly to a - // generic directory file mapping; the host-side baseline merge below is - // unchanged. - const operations: SandboxSyncOperation[] = [{ - operationId: nextSyncOperationId(), - files: [{ - sourcePath: workspaceRemoteDir, - targetPath: extractedDir, - kind: "directory", - exclude: restoreExclude, - }], - }]; - assertSyncOperationsConfined(operations, { - sourceRoots: [workspaceRemoteDir], - targetRoots: [extractedDir], }); - await fs.mkdir(extractedDir, { recursive: true }); - const workspaceRestore = makeTransferProgress( - restoreSink, - "Restoring", - "from", - "workspace", - { sink: input.onRuntimeProgress, phase: "restore" }, - ); - await input.client.syncOut!(operations); - await workspaceRestore.finish(0, 0); - } else { - const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar"); - await input.client.run( - `sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({ - remoteDir: workspaceRemoteDir, - archivePath: remoteWorkspaceTar, - exclude: restoreExclude, - }))}`, - { timeoutMs: input.spec.timeoutMs }, - ); - const workspaceRestore = makeTransferProgress( - restoreSink, - "Restoring", - "from", - "workspace", - { sink: input.onRuntimeProgress, phase: "restore" }, - ); - const archiveBytes = await input.client.readFile(remoteWorkspaceTar, workspaceRestore.options); - const archiveBuffer = toBuffer(archiveBytes); - await workspaceRestore.finish(archiveBuffer.byteLength, archiveBuffer.byteLength); - await input.client.remove(remoteWorkspaceTar).catch(() => undefined); - const localArchivePath = path.join(tempDir, "workspace.tar"); - await fs.writeFile(localArchivePath, archiveBuffer); - await extractTarballToDirectory({ - archivePath: localArchivePath, - localDir: extractedDir, - }); - } - const gitHeadToIntegrate = importedHead; - await mergeDirectoryWithBaseline({ - baseline: baselineSnapshot!, - sourceDir: extractedDir, - targetDir: input.workspaceLocalDir, - beforeApply: gitHeadToIntegrate - ? async () => { - await integrateImportedGitHead({ - localDir: input.workspaceLocalDir, - importedHead: gitHeadToIntegrate, - }); - } - : undefined, - afterApply: gitSnapshot - ? async () => { - await resetLocalGitIndexToHead({ - localDir: input.workspaceLocalDir, - checkWorkingTreeClean: remoteWorkspaceStatus === "clean", - }); - } - : undefined, - }); + }), + ); + } - // Per-asset teardown/outbound contributions. Generic: an asset with - // no `restore` is a no-op. The contribution reads back from the - // sandbox (e.g. a refreshed credential) via the provided `readFile`. - for (const asset of input.assets ?? []) { - if (!asset.restore) continue; - await asset.restore({ - assetDir: path.posix.join(runtimeRootDir, asset.key), - readFile: async (remotePath) => toBuffer(await input.client.readFile(remotePath)), - }); - } - } finally { - await emitRuntimeStatus(input.onRuntimeProgress, "finalize", "Finalizing sandbox workspace"); - if (importedRef) { - await deleteLocalGitRef({ localDir: input.workspaceLocalDir, ref: importedRef }); - } + // Run every outbound restore through the shared scheduler. It starts the + // tasks under the client concurrency permission and the bound, and it + // settles every started task before it returns. This is the teardown + // barrier. + const outboundResults = await scheduleSyncOperations( + outboundTasks, + input.client.allowConcurrentSyncOperations === true, + SYNC_OPERATION_CONCURRENCY_LIMIT, + ); + + // Every outbound restore is a required operation: a rejection is fatal. + // The workspace comes first, then each asset in order. Raise the first + // rejection in stable task order, so two failures raise the earlier one. + for (const result of outboundResults) { + if (result.status === "rejected") { + throw result.reason; } - }); + } }, }; } diff --git a/packages/adapter-utils/src/sync-operation-schedule.test.ts b/packages/adapter-utils/src/sync-operation-schedule.test.ts new file mode 100644 index 00000000000..edcfb5ae3df --- /dev/null +++ b/packages/adapter-utils/src/sync-operation-schedule.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import { + SYNC_OPERATION_CONCURRENCY_LIMIT, + scheduleSyncOperations, +} from "./sync-operation-schedule.js"; + +// A deferred task fake. The `task` thunk records that it started and returns a +// promise that the test resolves or rejects by hand. The fake lets a test hold +// a task open and check the exact moment a later task starts. +interface DeferredTask { + readonly task: () => Promise; + resolve(value: T): void; + reject(reason: unknown): void; + started(): boolean; +} + +function makeDeferred(): DeferredTask { + let started = false; + let resolveFn!: (value: T) => void; + let rejectFn!: (reason: unknown) => void; + const promise = new Promise((resolve, reject) => { + resolveFn = resolve; + rejectFn = reject; + }); + return { + task: () => { + started = true; + return promise; + }, + resolve: (value: T) => resolveFn(value), + reject: (reason: unknown) => rejectFn(reason), + started: () => started, + }; +} + +// Drain the microtask and timer queues so every pending worker step runs. +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("scheduleSyncOperations", () => { + it("exposes the shared bound constant as 4", () => { + expect(SYNC_OPERATION_CONCURRENCY_LIMIT).toBe(4); + }); + + it("keeps at most `bound` tasks active in concurrent mode", async () => { + const deferreds = [makeDeferred(), makeDeferred(), makeDeferred()]; + const tasks = deferreds.map((deferred) => deferred.task); + + const scheduled = scheduleSyncOperations(tasks, true, 2); + await flush(); + + // The bound is 2, so only the first two tasks start. + expect(deferreds[0].started()).toBe(true); + expect(deferreds[1].started()).toBe(true); + expect(deferreds[2].started()).toBe(false); + + // One active task settles. A worker frees, so the third task starts. + deferreds[0].resolve(0); + await flush(); + expect(deferreds[2].started()).toBe(true); + + deferreds[1].resolve(1); + deferreds[2].resolve(2); + const results = await scheduled; + expect(results).toEqual([ + { status: "fulfilled", value: 0 }, + { status: "fulfilled", value: 1 }, + { status: "fulfilled", value: 2 }, + ]); + }); + + it("runs one task at a time in input order in serial mode", async () => { + const deferreds = [makeDeferred(), makeDeferred(), makeDeferred()]; + const tasks = deferreds.map((deferred) => deferred.task); + + const scheduled = scheduleSyncOperations(tasks, false); + await flush(); + + // Serial mode holds one task active, so only the first task starts. + expect(deferreds[0].started()).toBe(true); + expect(deferreds[1].started()).toBe(false); + expect(deferreds[2].started()).toBe(false); + + deferreds[0].resolve(0); + await flush(); + // The next task starts only after the previous task settles. + expect(deferreds[1].started()).toBe(true); + expect(deferreds[2].started()).toBe(false); + + deferreds[1].resolve(1); + await flush(); + expect(deferreds[2].started()).toBe(true); + + deferreds[2].resolve(2); + const results = await scheduled; + expect(results).toEqual([ + { status: "fulfilled", value: 0 }, + { status: "fulfilled", value: 1 }, + { status: "fulfilled", value: 2 }, + ]); + }); + + it("waits for every started task to settle when one rejects", async () => { + const first = makeDeferred(); + const second = makeDeferred(); + const tasks = [first.task, second.task]; + + const scheduled = scheduleSyncOperations(tasks, true, 2); + let settled = false; + void scheduled.then(() => { + settled = true; + }); + await flush(); + + // Both tasks are active under the bound of 2. + expect(first.started()).toBe(true); + expect(second.started()).toBe(true); + + // The first task rejects, but the second task stays open. + first.reject(new Error("first failed")); + await flush(); + // The scheduler does not return before every started task settles. + expect(settled).toBe(false); + + second.resolve(2); + const results = await scheduled; + expect(settled).toBe(true); + // The results keep input order, and the rejection carries its reason. + expect(results[0]).toEqual({ status: "rejected", reason: new Error("first failed") }); + expect(results[1]).toEqual({ status: "fulfilled", value: 2 }); + }); + + // One table proves settle-all and input-order for both call modes. Both future + // call sites share these proven cases. + const MODE_TABLE = [ + { name: "serial mode", concurrent: false, bound: SYNC_OPERATION_CONCURRENCY_LIMIT }, + { name: "concurrent mode", concurrent: true, bound: 2 }, + ]; + + for (const mode of MODE_TABLE) { + it(`returns settled results in input order in ${mode.name}`, async () => { + // The tasks settle out of order: index 2 first, then index 0, then a + // rejection at index 1. The result array must still keep input order. + const failure = new Error("index one failed"); + const tasks: Array<() => Promise> = [ + () => Promise.resolve("zero"), + () => Promise.reject(failure), + () => Promise.resolve("two"), + ]; + + const results = await scheduleSyncOperations(tasks, mode.concurrent, mode.bound); + + expect(results).toEqual([ + { status: "fulfilled", value: "zero" }, + { status: "rejected", reason: failure }, + { status: "fulfilled", value: "two" }, + ]); + }); + } + + it("returns an empty result list for no tasks", async () => { + const results = await scheduleSyncOperations([], true, 4); + expect(results).toEqual([]); + }); +}); diff --git a/packages/adapter-utils/src/sync-operation-schedule.ts b/packages/adapter-utils/src/sync-operation-schedule.ts new file mode 100644 index 00000000000..7ba65bdbe25 --- /dev/null +++ b/packages/adapter-utils/src/sync-operation-schedule.ts @@ -0,0 +1,79 @@ +// A direction-agnostic scheduler for file-sync operations. Both sync directions +// share this one function. The scheduler starts operation tasks under a bound, +// keeps serial order when concurrency is off, and settles every started task +// before it returns. It stays pure: it imports no runtime or provider module. + +// The maximum number of active sync operations when concurrency is on. A caller +// passes this as the bound. Four keeps a useful parallel width without a burst +// of open file handles. +export const SYNC_OPERATION_CONCURRENCY_LIMIT = 4; + +// A sync operation. The scheduler calls the thunk to start the operation, so the +// scheduler controls the exact start time and can hold the bound. +export type SyncOperationTask = () => Promise; + +// Resolve the number of active tasks the scheduler allows. +// Serial mode allows one active task. Concurrent mode allows the bound, with a +// floor of one so a bad bound never stalls the scheduler. +function resolveActiveLimit(concurrent: boolean, bound: number): number { + if (!concurrent) { + return 1; + } + const flooredBound = Math.floor(bound); + if (!Number.isFinite(flooredBound) || flooredBound < 1) { + return 1; + } + return flooredBound; +} + +/** + * Run an ordered list of sync operation tasks and settle every started task. + * + * When `concurrent` is false, the scheduler runs the tasks one at a time in + * input order. When `concurrent` is true, the scheduler keeps at most `bound` + * tasks active. The scheduler always waits for every started task to settle + * before it returns. It returns the settled results in input order. + * + * @param tasks Ordered task list. The scheduler calls each thunk to start it. + * @param concurrent Turn concurrency on or off. + * @param bound Maximum active tasks when `concurrent` is true. + * @returns Settled results in input order, one per task. + */ +export async function scheduleSyncOperations( + tasks: ReadonlyArray>, + concurrent: boolean, + bound: number = SYNC_OPERATION_CONCURRENCY_LIMIT, +): Promise>> { + const results = new Array>(tasks.length); + const activeLimit = resolveActiveLimit(concurrent, bound); + + // A shared cursor over the input list. Each worker takes the next task in + // input order. The cursor keeps serial order and holds the active bound. + let nextIndex = 0; + + // One worker settles tasks until the list is empty. A worker catches its own + // task rejection, so a rejection never stops the other workers and never + // rejects the scheduler. This guarantees settle-all. + async function runWorker(): Promise { + while (nextIndex < tasks.length) { + const currentIndex = nextIndex; + nextIndex += 1; + try { + const value = await tasks[currentIndex](); + results[currentIndex] = { status: "fulfilled", value }; + } catch (reason) { + results[currentIndex] = { status: "rejected", reason }; + } + } + } + + // Start at most `activeLimit` workers, and never more than the task count. + const workerCount = Math.min(activeLimit, tasks.length); + const workers: Array> = []; + for (let worker = 0; worker < workerCount; worker += 1) { + workers.push(runWorker()); + } + + await Promise.all(workers); + return results; +} diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index eb93a453315..38a25c59cc5 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -113,10 +113,11 @@ export interface AdapterExecutionResult { * Each referenced (mentioned) project that failed to stage into the remote sandbox for this run, * by `projectId`. The run continues without a failed project (per-project failure isolation); this * field carries the failure back so the server counts it in the requested-vs-synced observability - * instead of losing it to a warning line. Absent or empty on a local target, or when every staged - * referenced project succeeded. + * instead of losing it to a warning line. Each entry pairs the `projectId` with the failure + * `error`, so a reader of the run learns why the project dropped. Absent or empty on a local + * target, or when every staged referenced project succeeded. */ - referencedProjectStagingFailures?: Array<{ projectId: string }>; + referencedProjectStagingFailures?: Array<{ projectId: string; error: string }>; summary?: string | null; clearSession?: boolean; question?: { diff --git a/packages/plugins/sandbox-providers/daytona/src/file-sync.test.ts b/packages/plugins/sandbox-providers/daytona/src/file-sync.test.ts new file mode 100644 index 00000000000..6b171e58636 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/file-sync.test.ts @@ -0,0 +1,155 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +// The plugin module imports `@daytonaio/sdk` as a value, but the sync tests never +// touch a real Daytona client — every sandbox call goes through a local mock. Stub +// the SDK so the import resolves without the excluded provider package. +import { vi } from "vitest"; +vi.mock("@daytonaio/sdk", () => ({ + Daytona: class MockDaytona {}, + DaytonaNotFoundError: class MockDaytonaNotFoundError extends Error {}, + DaytonaTimeoutError: class MockDaytonaTimeoutError extends Error {}, +})); + +import { performSyncIn } from "./file-sync.js"; +import type { PluginSyncOperation } from "@paperclipai/plugin-sdk"; + +// One recorded in-sandbox command, so a test can assert the exact cleanup command. +interface RecordedCommand { + command: string; +} + +// Build a mock Daytona sandbox for the inbound directory path. `executeCommand` +// records every command and returns exit 0, except that any command whose text +// matches `failCommandMatch` returns exit 1 (to simulate an extract failure). +// `uploadFiles` records each upload destination so a test can read the reserved +// scratch tar path the runtime chose. +function createMockSandbox(input: { + failCommandMatch?: RegExp; + uploadedDestinations: string[]; + commands: RecordedCommand[]; +}) { + return { + process: { + executeCommand: async (command: string) => { + input.commands.push({ command }); + if (input.failCommandMatch && input.failCommandMatch.test(command)) { + return { exitCode: 1, result: "simulated extract failure" }; + } + return { exitCode: 0, result: "" }; + }, + }, + fs: { + uploadFiles: async (uploads: Array<{ source: string; destination: string }>) => { + for (const upload of uploads) input.uploadedDestinations.push(upload.destination); + }, + }, + }; +} + +describe("daytona file-sync inbound scratch cleanup", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + it("removes the reserved scratch tar when a directory extraction fails", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-daytona-scratch-")); + cleanupDirs.push(rootDir); + const sourceDir = path.join(rootDir, "referenced-project"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.writeFile(path.join(sourceDir, "README.md"), "referenced project\n", "utf8"); + + const remoteDir = "/workspace"; + const targetPath = "/workspace/.paperclip-runtime/test-adapter/project-abc"; + const uploadedDestinations: string[] = []; + const commands: RecordedCommand[] = []; + // Fail the extract round trip (the only command that runs `tar -xf`). + const sandbox = createMockSandbox({ + failCommandMatch: /tar -xf/, + uploadedDestinations, + commands, + }); + + const operations: PluginSyncOperation[] = [{ + operationId: "sync-op-1", + files: [{ sourcePath: sourceDir, targetPath, kind: "directory" }], + }]; + + await expect( + performSyncIn({ + // The mock stands in for the Daytona SDK Sandbox; only the two methods the + // inbound directory path calls are needed. + sandbox: sandbox as never, + operations, + remoteDir, + timeoutSeconds: 30, + }), + ).rejects.toThrow(/syncIn extract/); + + // The runtime uploaded exactly one reserved scratch tar under the workspace + // root. Its name carries the reserved `.paperclip-upload-` prefix. + expect(uploadedDestinations).toHaveLength(1); + const scratchTar = uploadedDestinations[0]; + expect(scratchTar).toContain(".paperclip-upload-"); + expect(scratchTar.startsWith(`${remoteDir}/`)).toBe(true); + + // The failure path swept the scratch tar: a standalone `rm -f` of the exact + // scratch path ran after the failed extract. The extract command itself also + // contains an `rm -f`, so the cleanup is the `rm -f` command that does NOT run + // `tar -xf`. + const cleanupCommands = commands.filter( + (entry) => + entry.command.includes(scratchTar) && + entry.command.includes("rm -f") && + !entry.command.includes("tar -xf"), + ); + expect(cleanupCommands.length).toBeGreaterThan(0); + }); + + it("does not sweep scratch on the happy path (extract removes it)", async () => { + const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-daytona-scratch-")); + cleanupDirs.push(rootDir); + const sourceDir = path.join(rootDir, "referenced-project"); + await fs.mkdir(sourceDir, { recursive: true }); + await fs.writeFile(path.join(sourceDir, "README.md"), "referenced project\n", "utf8"); + + const remoteDir = "/workspace"; + const targetPath = "/workspace/.paperclip-runtime/test-adapter/project-abc"; + const uploadedDestinations: string[] = []; + const commands: RecordedCommand[] = []; + // No failure: every command succeeds, so the extract's own `rm -f` clears the + // scratch and no extra cleanup round trip runs. + const sandbox = createMockSandbox({ uploadedDestinations, commands }); + + const operations: PluginSyncOperation[] = [{ + operationId: "sync-op-1", + files: [{ sourcePath: sourceDir, targetPath, kind: "directory" }], + }]; + + await performSyncIn({ + sandbox: sandbox as never, + operations, + remoteDir, + timeoutSeconds: 30, + }); + + const scratchTar = uploadedDestinations[0]; + // The standalone cleanup command (a `rm -f` without `tar -xf`) never runs on the + // happy path — only the extract command, which ends with its own `rm -f`. + const standaloneRemoves = commands.filter( + (entry) => + entry.command.includes(scratchTar) && + entry.command.includes("rm -f") && + !entry.command.includes("tar -xf"), + ); + expect(standaloneRemoves).toHaveLength(0); + }); +}); diff --git a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts index 8a84dd5d34e..9a613dbd778 100644 --- a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts +++ b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts @@ -24,6 +24,10 @@ const SPAN_ATTR = { packWallMs: `${SPAN_ATTR_PREFIX}pack.wall_ms`, transferWallMs: `${SPAN_ATTR_PREFIX}transfer.wall_ms`, transferGuardCount: `${SPAN_ATTR_PREFIX}transfer.guard.count`, + // The transfer direction: `inbound` for an upload to the sandbox, `outbound` + // for a download from the sandbox. Operation identity comes from the parent + // span, so the transfer span never carries an operation label. + transferDirection: `${SPAN_ATTR_PREFIX}transfer.direction`, } as const; /** The value of `SpanStatusCode.ERROR` in `@opentelemetry/api`. The plugin stays @@ -532,7 +536,10 @@ async function syncInFileMappings(input: { await withProviderSpan({ name: "transfer", wallMsAttr: SPAN_ATTR.transferWallMs, - attributes: { [SPAN_ATTR.transferGuardCount]: guardRoundTrips }, + attributes: { + [SPAN_ATTR.transferGuardCount]: guardRoundTrips, + [SPAN_ATTR.transferDirection]: "inbound", + }, run: () => sandbox.fs.uploadFiles(uploads, timeoutSeconds), }); @@ -655,52 +662,67 @@ async function syncInDirectoryMapping(input: { }), }); guardRoundTrips += 1; - // `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`. - await withProviderSpan({ - name: "transfer", - wallMsAttr: SPAN_ATTR.transferWallMs, - attributes: { [SPAN_ATTR.transferGuardCount]: guardRoundTrips }, - run: () => - sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds), - }); - // Bind validation and extraction into ONE sandbox invocation, then extract into - // an OPEN directory inode rather than a path string. `exec 9<"$_pc_real"` itself - // walks every ancestor of `$_pc_real` during the `open()` syscall, so a sandbox - // process that swaps an ancestor component for a symlink AFTER `_pc_resolve` - // returns but BEFORE the `open()` resolves would leave fd 9 pointing at a - // directory outside the workspace — the earlier `case` check on the resolved - // string cannot see that. Close the gap with open-then-verify: open fd 9 (which - // PINS whatever inode `open()` landed on), then re-canonicalize `/proc/self/fd/9` - // — the pinned inode's own path — and confirm it is still inside `$_pc_root` - // before extracting. If an ancestor swap redirected the open, the pinned inode - // resolves outside the root and the verify fails closed (exit 42); once the - // verify passes, the inode is fixed and `tar -C /proc/self/fd/9` chdir's through - // the magic symlink to that exact inode, so a post-open ancestor swap cannot - // redirect the write. (The initial `case` on `$_pc_real` still fails fast on a - // pre-open escape; the fd re-verify is what makes the guarantee race-free.) - const extractScript = [ - ...canonicalizerPreamble(shellQuote(remoteDir)), - `_pc_real=$(_pc_resolve ${shellQuote(mapping.targetPath)}) || { echo "ESCAPE"; exit 42; };`, - `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`, - `exec 9<"$_pc_real" || { echo "open failed"; exit 46; };`, - `_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE"; exit 42; };`, - `case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`, - `tar -xf ${shellQuote(remoteTar)} -C /proc/self/fd/9 || { echo "extract failed"; exit 43; };`, - `exec 9>&-;`, - `rm -f ${shellQuote(remoteTar)};`, - ].join("\n"); - // `extractTarball` span: one round trip — re-check the path, `tar -xf`, and - // remove the scratch tarball. - await withProviderSpan({ - name: "extractTarball", - run: () => - assertSandboxCommandOk( - sandbox, - `sh -c ${shellQuote(extractScript)}`, - timeoutSeconds, - "syncIn extract", - ), - }); + // The uploaded scratch tar lands at the workspace root as a reserved + // `.paperclip-upload-*` entry. The extract script below removes it only on + // success. On an upload or extract failure the scratch tar can remain, and the + // runtime workspace wipe preserves every `.paperclip-upload-*` entry, so a + // stale tar would surface in the agent workspace. Sweep the scratch on any + // failure — symmetric with the file-mapping path — so a failed sync (for + // example a referenced-project extraction) leaves no residue. + try { + // `transfer` span: the real byte upload — `sandbox.fs.uploadFiles`. + await withProviderSpan({ + name: "transfer", + wallMsAttr: SPAN_ATTR.transferWallMs, + attributes: { + [SPAN_ATTR.transferGuardCount]: guardRoundTrips, + [SPAN_ATTR.transferDirection]: "inbound", + }, + run: () => + sandbox.fs.uploadFiles([{ source: archivePath, destination: remoteTar }], timeoutSeconds), + }); + // Bind validation and extraction into ONE sandbox invocation, then extract into + // an OPEN directory inode rather than a path string. `exec 9<"$_pc_real"` itself + // walks every ancestor of `$_pc_real` during the `open()` syscall, so a sandbox + // process that swaps an ancestor component for a symlink AFTER `_pc_resolve` + // returns but BEFORE the `open()` resolves would leave fd 9 pointing at a + // directory outside the workspace — the earlier `case` check on the resolved + // string cannot see that. Close the gap with open-then-verify: open fd 9 (which + // PINS whatever inode `open()` landed on), then re-canonicalize `/proc/self/fd/9` + // — the pinned inode's own path — and confirm it is still inside `$_pc_root` + // before extracting. If an ancestor swap redirected the open, the pinned inode + // resolves outside the root and the verify fails closed (exit 42); once the + // verify passes, the inode is fixed and `tar -C /proc/self/fd/9` chdir's through + // the magic symlink to that exact inode, so a post-open ancestor swap cannot + // redirect the write. (The initial `case` on `$_pc_real` still fails fast on a + // pre-open escape; the fd re-verify is what makes the guarantee race-free.) + const extractScript = [ + ...canonicalizerPreamble(shellQuote(remoteDir)), + `_pc_real=$(_pc_resolve ${shellQuote(mapping.targetPath)}) || { echo "ESCAPE"; exit 42; };`, + `case "$_pc_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`, + `exec 9<"$_pc_real" || { echo "open failed"; exit 46; };`, + `_pc_fd_real=$(_pc_resolve /proc/self/fd/9) || { echo "ESCAPE"; exit 42; };`, + `case "$_pc_fd_real/" in "$_pc_root"/*) : ;; *) echo "ESCAPE"; exit 42 ;; esac;`, + `tar -xf ${shellQuote(remoteTar)} -C /proc/self/fd/9 || { echo "extract failed"; exit 43; };`, + `exec 9>&-;`, + `rm -f ${shellQuote(remoteTar)};`, + ].join("\n"); + // `extractTarball` span: one round trip — re-check the path, `tar -xf`, and + // remove the scratch tarball. + await withProviderSpan({ + name: "extractTarball", + run: () => + assertSandboxCommandOk( + sandbox, + `sh -c ${shellQuote(extractScript)}`, + timeoutSeconds, + "syncIn extract", + ), + }); + } catch (error) { + await removeSandboxScratch(sandbox, [remoteTar], timeoutSeconds); + throw error; + } const filesTransferred = await countHostFiles(mapping.sourcePath, mapping.exclude); return { filesTransferred, bytesTransferred }; }); @@ -873,10 +895,25 @@ async function syncOutFileMappings(input: { throw error; } + // Count the serial sandbox round trips before the transfer, so the transfer + // span records how much of the wall time is guard cost. The validate-and- + // snapshot step is one sandbox round trip. This is symmetric with the inbound + // transfer span. + const guardRoundTrips = 1; + let responses: FileDownloadResponse[]; try { // One batched bulk download for all file mappings, reading the snapshots. - responses = await sandbox.fs.downloadFiles(requests, timeoutSeconds); + // `transfer` span: the real byte download — `sandbox.fs.downloadFiles`. + responses = await withProviderSpan({ + name: "transfer", + wallMsAttr: SPAN_ATTR.transferWallMs, + attributes: { + [SPAN_ATTR.transferGuardCount]: guardRoundTrips, + [SPAN_ATTR.transferDirection]: "outbound", + }, + run: () => sandbox.fs.downloadFiles(requests, timeoutSeconds), + }); } catch (error) { await cleanup(); throw error; @@ -926,6 +963,9 @@ async function syncOutDirectoryMapping(input: { }): Promise<{ filesTransferred: number; bytesTransferred: number }> { const { sandbox, mapping, remoteDir, timeoutSeconds } = input; assertConfinedSandboxPath(remoteDir, mapping.sourcePath, "source"); + // Count the serial sandbox round trips before the transfer, so the transfer + // span records how much of the wall time is guard cost. + let guardRoundTrips = 0; await assertSandboxPathsConfined({ sandbox, remoteDir, @@ -933,6 +973,7 @@ async function syncOutDirectoryMapping(input: { timeoutSeconds, label: "outbound symlink-escape guard", }); + guardRoundTrips += 1; return withHostTempDir(async (tmp) => { const remoteTar = path.posix.join(remoteDir, scratchName(".tar")); @@ -951,14 +992,22 @@ async function syncOutDirectoryMapping(input: { `else tar -c --no-xattrs ${mapping.followSymlinks ? "-h " : ""}${excludeFlags} -f ${shellQuote(remoteTar)} -- "$@"; fi`, ].join(" && "); await assertSandboxCommandOk(sandbox, `sh -c ${shellQuote(tarScript)}`, timeoutSeconds, "syncOut tar"); + guardRoundTrips += 1; const localTar = path.join(tmp, "sync-out.tar"); let bytesTransferred = 0; try { - const responses = await sandbox.fs.downloadFiles( - [{ source: remoteTar, destination: localTar }], - timeoutSeconds, - ); + // `transfer` span: the real byte download — `sandbox.fs.downloadFiles`. + const responses = await withProviderSpan({ + name: "transfer", + wallMsAttr: SPAN_ATTR.transferWallMs, + attributes: { + [SPAN_ATTR.transferGuardCount]: guardRoundTrips, + [SPAN_ATTR.transferDirection]: "outbound", + }, + run: () => + sandbox.fs.downloadFiles([{ source: remoteTar, destination: localTar }], timeoutSeconds), + }); const response = responses.find((entry) => entry.source === remoteTar) ?? responses[0]; if (!response || response.error) { throw new Error( diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index 26f65f22140..149d284a305 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -1,12 +1,16 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; const PLUGIN_ID = "paperclip.daytona-sandbox-provider"; -// 0.1.3 renames the login transport flag from `supportsSetupTokenLogin` to the -// neutral `supportsLoginPty`. The boot reconcile reads the persisted manifest -// raw and does not re-run the validator, so it never canonicalizes the old -// name. The version bump makes the reconcile refresh the persisted manifest for -// an existing install, so the renamed capability propagates. -const PLUGIN_VERSION = "0.1.3"; +// The bundled-plugin boot reconcile refreshes the persisted manifest for an +// existing install only when PLUGIN_VERSION changes. A manifest change without a +// version bump never reaches an existing install. The reconcile also reads the +// persisted manifest raw and does not re-run the validator, so it never +// canonicalizes a renamed capability. +// +// 0.1.3 renamed the login transport flag from `supportsSetupTokenLogin` to the +// neutral `supportsLoginPty`. +// 0.1.4 adds the `concurrentSyncOperations` sandbox capability to the driver. +const PLUGIN_VERSION = "0.1.4"; const manifest: PaperclipPluginManifestV1 = { id: PLUGIN_ID, @@ -33,8 +37,16 @@ const manifest: PaperclipPluginManifestV1 = { // emits incremental session output while the command runs. Declare the // opt-in capability so the host selects the session-output streaming path. // A generic one-shot provider that omits this key keeps the poll path. + // + // Daytona also runs file transfers into and out of the sandbox in parallel. + // Each concurrent sync hook call uses separate temporary state (random + // scratch names and per-mapping host temporary directories), and teardown + // waits for all active calls. Declare the opt-in capability so the host may + // schedule sync operations concurrently. The host resolves it `true` only + // when the worker also verifies both sync verbs. sandboxCapabilities: { incrementalSessionOutput: true, + concurrentSyncOperations: true, }, supportsInteractiveSetup: true, interactiveSetupConnectionTypes: ["ssh"], diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index e6c85b06a4b..35021fac1f0 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -139,6 +139,15 @@ describe("Daytona sandbox provider plugin", () => { }); }); + it("declares the concurrent-sync-operations capability so the host may parallelize sync operations", () => { + // Daytona runs file transfers into and out of the sandbox in parallel, so it + // declares the opt-in capability. The host resolves it `true` only when the + // worker also verifies both sync verbs, which the sync hooks provide. + expect(manifest.environmentDrivers?.[0]?.sandboxCapabilities).toMatchObject({ + concurrentSyncOperations: true, + }); + }); + it("normalizes config and validates the API key fallback", async () => { process.env.DAYTONA_API_KEY = "host-key"; @@ -3014,6 +3023,95 @@ describe("daytona native file-sync hooks", () => { }; } + // A concurrency gate for a fake transfer call (uploadFiles / downloadFiles). + // The gate lets two concurrent hook calls both enter the fake, then holds them + // there until the test releases them. It records the peak number of calls that + // are in the fake at the same time, so a test proves the two calls overlap. + // + // `expected` is how many calls the test starts. `bothArrived` resolves once + // that many calls sit inside the fake at the same moment. `release()` frees + // them. `body` is the fake implementation: it marks arrival, waits for the + // release, and then runs `onRelease` to produce the fake result. + function createTransferGate(expected: number, onRelease: (args: unknown[]) => Promise) { + let inFlight = 0; + let peakInFlight = 0; + let signalArrived!: () => void; + const bothArrived = new Promise((resolve) => { + signalArrived = resolve; + }); + let signalReleased!: () => void; + const released = new Promise((resolve) => { + signalReleased = resolve; + }); + const body = async (...args: unknown[]): Promise => { + inFlight += 1; + peakInFlight = Math.max(peakInFlight, inFlight); + if (inFlight === expected) signalArrived(); + await released; + inFlight -= 1; + return onRelease(args); + }; + return { + body, + bothArrived, + release: () => signalReleased(), + peak: () => peakInFlight, + }; + } + + // Write each download request's snapshot bytes to its host destination and + // report success, matching the real batch-download contract the outbound sync + // path expects. + async function fulfilDownload(args: unknown[]): Promise> { + const requests = args[0] as Array<{ source: string; destination: string }>; + return Promise.all( + requests.map(async (request) => { + await fs.writeFile(request.destination, "bytes"); + return { source: request.source, result: request.destination }; + }), + ); + } + + function syncInParams(overrides: { + operationId: string; + sourcePath: string; + targetPath: string; + }) { + return { + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: overrides.operationId, + files: [{ sourcePath: overrides.sourcePath, targetPath: overrides.targetPath, kind: "file" as const }], + }, + ], + }; + } + + function syncOutParams(overrides: { + operationId: string; + sourcePath: string; + targetPath: string; + }) { + return { + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: overrides.operationId, + files: [{ sourcePath: overrides.sourcePath, targetPath: overrides.targetPath, kind: "file" as const }], + }, + ], + }; + } + beforeEach(() => { mockGet.mockReset(); process.env.DAYTONA_API_KEY = "host-key"; @@ -3295,6 +3393,66 @@ describe("daytona native file-sync hooks", () => { expect(spans.find((span) => span.name === "pack")).toBeUndefined(); }); + it("marks the inbound transfer span with the inbound direction attribute", async () => { + const hostDir = await makeHostDir(); + const source = path.join(hostDir, "config.txt"); + await fs.writeFile(source, "plain"); + const sandbox = createMockSandbox(); + mockGet.mockResolvedValue(sandbox); + + const { tracer, spans } = createRecordingPluginTracer(); + const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext); + try { + await plugin.definition.onEnvironmentSyncIn?.( + syncInParams({ + operationId: "sync-op-in-dir", + sourcePath: source, + targetPath: `${REMOTE_DIR}/config.txt`, + }), + ); + } finally { + restore(); + } + + // An upload to the sandbox is an inbound transfer. + const transfer = spans.find((span) => span.name === "transfer"); + expect(transfer).toBeDefined(); + expect(transfer!.attributes["paperclip.sandbox.startup.transfer.direction"]).toBe("inbound"); + }); + + it("marks the outbound transfer span with the outbound direction attribute", async () => { + const hostDir = await makeHostDir(); + const sandbox = createMockSandbox(); + sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => { + return Promise.all( + requests.map(async (req) => { + await fs.writeFile(req.destination!, "bytes"); + return { source: req.source, result: req.destination }; + }), + ); + }); + mockGet.mockResolvedValue(sandbox); + + const { tracer, spans } = createRecordingPluginTracer(); + const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext); + try { + await plugin.definition.onEnvironmentSyncOut?.( + syncOutParams({ + operationId: "sync-op-out-dir", + sourcePath: `${REMOTE_DIR}/out/result.txt`, + targetPath: path.join(hostDir, "result.txt"), + }), + ); + } finally { + restore(); + } + + // A download from the sandbox is an outbound transfer. + const transfer = spans.find((span) => span.name === "transfer"); + expect(transfer).toBeDefined(); + expect(transfer!.attributes["paperclip.sandbox.startup.transfer.direction"]).toBe("outbound"); + }); + it("opens a pack span and a transfer span around a directory mapping sync", async () => { const hostDir = await makeHostDir(); const sourceDir = path.join(hostDir, "assets"); @@ -4530,6 +4688,239 @@ describe("daytona native file-sync hooks", () => { }); expect(withEmpty.process.executeCommand.mock.calls.length).toBe(baselineExecCount); }); + + it("runs two concurrent inbound syncIn calls with separate reserved scratch names", async () => { + const hostDir = await makeHostDir(); + const sourceA = path.join(hostDir, "a.txt"); + const sourceB = path.join(hostDir, "b.txt"); + await fs.writeFile(sourceA, "alpha"); + await fs.writeFile(sourceB, "beta"); + + const sandbox = createMockSandbox(); + // Gate the upload so both concurrent calls sit inside uploadFiles together. + const gate = createTransferGate(2, async () => undefined); + sandbox.fs.uploadFiles.mockImplementation(gate.body); + mockGet.mockResolvedValue(sandbox); + + const callA = plugin.definition.onEnvironmentSyncIn?.( + syncInParams({ operationId: "in-a", sourcePath: sourceA, targetPath: `${REMOTE_DIR}/a.txt` }), + ); + const callB = plugin.definition.onEnvironmentSyncIn?.( + syncInParams({ operationId: "in-b", sourcePath: sourceB, targetPath: `${REMOTE_DIR}/b.txt` }), + ); + + // Both calls reached the upload before either finished, so they overlap. + await gate.bothArrived; + expect(gate.peak()).toBe(2); + expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(2); + + gate.release(); + await Promise.all([callA, callB]); + + // Each concurrent call staged its upload under its own reserved scratch name; + // the two calls never share a temporary destination. + const destinations = sandbox.fs.uploadFiles.mock.calls.flatMap( + ([uploads]) => (uploads as Array<{ destination: string }>).map((upload) => upload.destination), + ); + expect(destinations).toHaveLength(2); + for (const destination of destinations) { + expect(path.posix.basename(destination)).toMatch(/^\.paperclip-upload-/); + expect(path.posix.dirname(destination)).toBe(REMOTE_DIR); + } + expect(new Set(destinations).size).toBe(destinations.length); + }); + + it("runs two concurrent outbound syncOut calls that both reach downloadFiles before either opens", async () => { + const hostDir = await makeHostDir(); + const targetA = path.join(hostDir, "a.txt"); + const targetB = path.join(hostDir, "b.txt"); + + const sandbox = createMockSandbox(); + // Gate the download so both concurrent calls sit inside downloadFiles + // together before either resolves. + const gate = createTransferGate(2, fulfilDownload); + sandbox.fs.downloadFiles.mockImplementation(gate.body); + mockGet.mockResolvedValue(sandbox); + + const callA = plugin.definition.onEnvironmentSyncOut?.( + syncOutParams({ operationId: "out-a", sourcePath: `${REMOTE_DIR}/a.txt`, targetPath: targetA }), + ); + const callB = plugin.definition.onEnvironmentSyncOut?.( + syncOutParams({ operationId: "out-b", sourcePath: `${REMOTE_DIR}/b.txt`, targetPath: targetB }), + ); + + // Both calls reached the download before either gate opened, so they overlap. + await gate.bothArrived; + expect(gate.peak()).toBe(2); + expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(2); + + gate.release(); + await Promise.all([callA, callB]); + + // Each concurrent call read its own reserved snapshot; the two calls never + // share a download source. + const sources = sandbox.fs.downloadFiles.mock.calls.flatMap( + ([requests]) => (requests as Array<{ source: string }>).map((request) => request.source), + ); + expect(sources).toHaveLength(2); + for (const source of sources) { + expect(source.startsWith(`${REMOTE_DIR}/`)).toBe(true); + expect(path.posix.basename(source)).toMatch(/^\.paperclip-upload-/); + } + expect(new Set(sources).size).toBe(sources.length); + expect(await fs.readFile(targetA, "utf8")).toBe("bytes"); + expect(await fs.readFile(targetB, "utf8")).toBe("bytes"); + }); + + it("waits for one active inbound and one active outbound call before teardown releases the sandbox", async () => { + const hostDir = await makeHostDir(); + const inboundSource = path.join(hostDir, "in.txt"); + const outboundTarget = path.join(hostDir, "out.txt"); + await fs.writeFile(inboundSource, "inbound"); + + const sandbox = createMockSandbox({ id: "sandbox-123" }); + // Hold the inbound upload and the outbound download open at the same time, so + // the shared lease has two active sync calls when teardown starts. + let releaseUpload!: () => void; + sandbox.fs.uploadFiles.mockImplementation(async () => { + await new Promise((resolve) => { + releaseUpload = resolve; + }); + }); + let releaseDownload!: () => void; + sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination: string }>) => { + await new Promise((resolve) => { + releaseDownload = resolve; + }); + return Promise.all( + requests.map(async (request) => { + await fs.writeFile(request.destination, "bytes"); + return { source: request.source, result: request.destination }; + }), + ); + }); + mockGet.mockResolvedValue(sandbox); + + const inboundCall = plugin.definition.onEnvironmentSyncIn?.( + syncInParams({ operationId: "in-active", sourcePath: inboundSource, targetPath: `${REMOTE_DIR}/in.txt` }), + ); + const outboundCall = plugin.definition.onEnvironmentSyncOut?.( + syncOutParams({ operationId: "out-active", sourcePath: `${REMOTE_DIR}/out.txt`, targetPath: outboundTarget }), + ); + // Let both sync calls register on the activity gate and reach their hung + // transfer, so teardown sees a refCount of two. + await new Promise((resolve) => setTimeout(resolve, 0)); + + const destroyCall = plugin.definition.onEnvironmentDestroyLease?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "sandbox-123", + config: { timeoutMs: 300000, reuseLease: false }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Two active sync calls block teardown, so it must not delete the sandbox yet. + expect(sandbox.delete).not.toHaveBeenCalled(); + + // Release only the inbound call. One outbound call is still active, so + // teardown must keep waiting. + releaseUpload(); + await inboundCall; + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(sandbox.delete).not.toHaveBeenCalled(); + + // Release the outbound call. No sync call is active now, so teardown deletes. + releaseDownload(); + await Promise.all([outboundCall, destroyCall]); + + expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1); + expect(sandbox.fs.downloadFiles).toHaveBeenCalledTimes(1); + expect(sandbox.delete).toHaveBeenCalledTimes(1); + }); + + it("opens a transfer span with the guard round-trip count around the bulk file download", async () => { + const hostDir = await makeHostDir(); + const target = path.join(hostDir, "result.txt"); + const sandbox = createMockSandbox(); + sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination: string }>) => { + return Promise.all( + requests.map(async (request) => { + await fs.writeFile(request.destination, "bytes"); + return { source: request.source, result: request.destination }; + }), + ); + }); + mockGet.mockResolvedValue(sandbox); + + const { tracer, spans } = createRecordingPluginTracer(); + const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext); + try { + await plugin.definition.onEnvironmentSyncOut?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "sync-op-out", + files: [{ sourcePath: `${REMOTE_DIR}/out/result.txt`, targetPath: target, kind: "file" }], + }, + ], + }); + } finally { + restore(); + } + + const transfer = spans.find((span) => span.name === "transfer"); + expect(transfer).toBeDefined(); + expect(transfer!.ended).toBe(true); + // One serial guard round trip before the transfer: the validate-and-snapshot. + expect(transfer!.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(1); + expect(transfer!.attributes["paperclip.sandbox.startup.provider"]).toBe("daytona"); + expect(typeof transfer!.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number"); + }); + + it("opens a transfer span around a directory-mapping download with the guard round-trip count", async () => { + const hostDir = await makeHostDir(); + const targetDir = path.join(hostDir, "assets"); + const sandbox = createMockSandbox(); + sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination: string }>) => { + // Write a valid empty tar (1024-byte zero EOF marker) so host-side extract + // is a clean no-op. + await Promise.all(requests.map((request) => fs.writeFile(request.destination, Buffer.alloc(1024)))); + return requests.map((request) => ({ source: request.source, result: request.destination })); + }); + mockGet.mockResolvedValue(sandbox); + + const { tracer, spans } = createRecordingPluginTracer(); + const restore = __setDaytonaPluginContextForTest({ tracer } as unknown as PluginContext); + try { + await plugin.definition.onEnvironmentSyncOut?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "sync-op-out-dir", + files: [{ sourcePath: `${REMOTE_DIR}/out/assets`, targetPath: targetDir, kind: "directory" }], + }, + ], + }); + } finally { + restore(); + } + + const transfer = spans.find((span) => span.name === "transfer"); + expect(transfer).toBeDefined(); + expect(transfer!.ended).toBe(true); + // Two serial guard round trips before the transfer: confinement + in-sandbox + // tar. + expect(transfer!.attributes["paperclip.sandbox.startup.transfer.guard.count"]).toBe(2); + expect(typeof transfer!.attributes["paperclip.sandbox.startup.transfer.wall_ms"]).toBe("number"); + }); }); describe("daytona manifest memory config", () => { diff --git a/packages/shared/src/telemetry/README.md b/packages/shared/src/telemetry/README.md index 7eb7c40f9a5..6c6f8d03c77 100644 --- a/packages/shared/src/telemetry/README.md +++ b/packages/shared/src/telemetry/README.md @@ -111,10 +111,16 @@ absent, never a misleading `0`. | `stage.sync` | Workspace stage-sync step. | `sandbox.startup` | | `snapshot.git` | Host-side git workspace enumeration inside `stage.sync` (`git status --ignored`, the HEAD diffs, `ls-files`). | `stage.sync` | | `snapshot.baseline` | Host-side baseline workspace content-hash walk inside `stage.sync`, kept for restore. | `stage.sync` | -| `pack` | Host-side workspace tarball build inside `stage.sync`. | `stage.sync` | +| `stage.workspace` | One inbound workspace stage task inside `stage.sync`. It packs and uploads the workspace. | `stage.sync` | +| `stage.asset.` | One inbound asset stage task inside `stage.sync`. It packs and uploads one managed-home asset. The `` segment is the asset key. | `stage.sync` | +| `stage.project.` | One inbound referenced-project stage task inside `stage.sync`. It uploads one referenced project. The `` segment is the project id. | `stage.sync` | +| `pack` | Host-side workspace tarball build inside the `stage.workspace` task. | `stage.workspace` | | `bridge.paperclip` | Paperclip bridge start step. | `sandbox.startup` | | `bridge.process-session` | Process-session bridge start step. | `sandbox.startup` | | `acp.handshake` | ACP session handshake step. | `sandbox.startup` | +| `sandbox.syncBack` | The settlement sync-back that restores the managed home at teardown. | the active run span | +| `restore.workspace` | One outbound workspace restore task at teardown. It reads the sandbox workspace back and merges it into the host workspace. | `sandbox.syncBack` | +| `restore.asset.` | One outbound asset restore task at teardown. It reads one asset back to its host store. The `` segment is the asset key. | `sandbox.syncBack` | | `sandbox.agentSession.sendInput` | One outbound ACP message to the agent — the socket handler's one `writeTextFile` exec. | the active run span | | `sandbox.agentSession.pollOutput` | One 100 ms poll tick — `list`, then `read`+`remove` per file found (`1 + 2n` execs). | the active run span | | `sandbox.callbackBridge.relayRequest` | One Paperclip-API callback request — read the request, write the response, remove it. | the active run span | @@ -123,9 +129,19 @@ absent, never a misleading `0`. A step span name is the step name. The `sandbox.exec` span parents to the step span that runs the execution, so each execution nests under its step. Within -`stage.sync`, the host-side sub-steps `snapshot.git`, `snapshot.baseline`, and -`pack` open as child spans of the step, so the host work at the head of the step -is attributed rather than showing as a gap. A run-time +`stage.sync`, the host-side sub-steps `snapshot.git` and `snapshot.baseline` open +as child spans of the step, so the host work at the head of the step is +attributed rather than showing as a gap. Each inbound sync operation also opens +its own task span under `stage.sync`: `stage.workspace`, one `stage.asset.` +per asset, and one `stage.project.` per referenced project. The `pack` span +nests under `stage.workspace`, because the host builds the tarball inside that +task. Two concurrent tasks produce overlapping spans. + +The settlement `sandbox.syncBack` span runs at teardown and parents to the run +span. It wraps the managed-home restore. Each outbound restore operation opens +its own task span under `sandbox.syncBack`: `restore.workspace` and one +`restore.asset.` per asset. Two concurrent restore tasks produce overlapping +spans. A run-time `sandbox.exec` span parents instead to the run-time wrapper span that runs it (`sandbox.agentSession.sendInput`, `sandbox.agentSession.pollOutput`, `sandbox.callbackBridge.relayRequest`, or `sandbox.agentProcess`). Each run-time @@ -228,7 +244,7 @@ before it records the span. | Span | Scope | Parent | | --- | --- | --- | | `sandbox.daytona.pack` | The host-local pack step that builds the upload tarball. It makes no sandbox round trip. | the active startup step span | -| `sandbox.daytona.transfer` | The transfer step that uploads the files to the sandbox. | the active startup step span | +| `sandbox.daytona.transfer` | The transfer step: an upload to the sandbox (inbound) or a download from the sandbox (outbound). The `paperclip.sandbox.startup.transfer.direction` attribute records the direction. | the active sync task span (`stage.*` inbound, `restore.*` under `sandbox.syncBack` outbound) | | `sandbox.daytona.ensureDirectory` | The `mkdir -p` step that ensures a directory exists before a write. | the active startup step span | | `sandbox.daytona.checkSymlinkEscape` | The re-check step that a path resolves inside the workspace root before use. | the active startup step span | | `sandbox.daytona.promote` | The atomic move of a staged temp onto its target via a pinned dir handle. | the active startup step span | @@ -258,10 +274,12 @@ the attributes that the producer sends for one span. | `paperclip.sandbox.startup.pack.wall_ms` | number | yes | The host-local wall time of the pack step. It rides the `sandbox.daytona.pack` span. | | `paperclip.sandbox.startup.transfer.wall_ms` | number | yes | The wall time of the transfer step. It rides the `sandbox.daytona.transfer` span. | | `paperclip.sandbox.startup.transfer.guard.count` | number | yes | The number of serial guard round trips before one transfer. It rides the `sandbox.daytona.transfer` span. | +| `paperclip.sandbox.startup.transfer.direction` | string | yes | The transfer direction (`inbound` or `outbound`). It rides the `sandbox.daytona.transfer` span. | The `span.record` host handler enforces the allowlist. It re-maps `provider` through the provider-family normalizer. It keeps `outcome` only when the value -is `ok`, `skipped`, or `failed`. It keeps a numeric attribute only when the +is `ok`, `skipped`, or `failed`. It keeps `transfer.direction` only when the +value is `inbound` or `outbound`. It keeps a numeric attribute only when the value is a finite number. It drops a status message and keeps only the numeric status code. The handler never throws, because observability must not change the sync control flow. @@ -271,9 +289,14 @@ capability. So only a plugin that registers an environment driver may emit a provider span. The capability gate rejects a provider span from any other plugin. -The host parents each provider span to the active startup step span. The host -mints a W3C `traceparent` from the active step and passes it to the plugin -worker on the per-call invocation channel. The worker tags its span with the +The host parents each provider span to the active sync task span. An inbound +transfer runs inside a `stage.*` task span, so its provider spans parent there. +An outbound transfer runs inside a `restore.*` task span under `sandbox.syncBack` +at teardown, so its provider spans parent there. The host mints a W3C +`traceparent` from the active task span and passes it to the plugin worker on the +per-call invocation channel. The teardown restore runs inside the run-parented +`sandbox.syncBack` span, so the host mints a `traceparent` for an outbound +provider span the same way it does for an inbound one. The worker tags its span with the `traceparent` and treats the value as opaque. The worker never derives the parent from it. The host recovers the `traceparent` from its own invocation record, so a worker can never forge a parent. The host validates the diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 7bbd845a45d..5a599fa82a1 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -163,6 +163,16 @@ export interface SandboxProviderCapabilities { * the output-file poll path. */ incrementalSessionOutput?: boolean; + /** + * Provider can run file transfers into and out of the sandbox in parallel, in + * both directions. This is an opt-in behavioral guarantee. An omitted key + * denies the capability, so the host keeps the serial transfer path. The host + * resolves the capability `true` only when the provider declares this key + * `true` and the live worker verifies both sync verbs (`environmentSyncIn` and + * `environmentSyncOut`). A provider that verifies only one verb resolves + * `false`. + */ + concurrentSyncOperations?: boolean; } export interface PluginEnvironmentDriverDeclaration { diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 093d15dde6d..ff2e7a99353 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -313,18 +313,29 @@ describe("sandbox provider capability declaration validators", () => { expect(rejected.success).toBe(false); }); - it("test_removed_concurrency_capabilities_are_rejected_as_unknown_keys", () => { - // The concurrency flags left the public contract because no runtime path - // enforced them. The strict schema now rejects them, so a manifest cannot - // declare a capability the host does not honor. - for (const key of ["concurrentSyncAndExec", "concurrentSyncOperations"]) { - const rejected = pluginManifestV1Schema.safeParse( - buildSandboxProviderManifest({ - sandboxCapabilities: { [key]: true }, - }), - ); - expect(rejected.success).toBe(false); - } + it("test_manifest_accepts_concurrent_sync_operations_capability", () => { + // A provider opts in to parallel bidirectional file sync with this key. The + // strict schema accepts it and keeps the declared value. + const parsed = pluginManifestV1Schema.parse( + buildSandboxProviderManifest({ + sandboxCapabilities: { concurrentSyncOperations: true }, + }), + ); + + expect(parsed.environmentDrivers?.[0]?.sandboxCapabilities).toEqual({ + concurrentSyncOperations: true, + }); + }); + + it("test_manifest_rejects_unknown_sync_concurrency_capability_key", () => { + // A neighboring but unknown concurrency key must fail validation, not drop + // silently. The strict schema rejects a capability the host does not honor. + const rejected = pluginManifestV1Schema.safeParse( + buildSandboxProviderManifest({ + sandboxCapabilities: { concurrentSyncAndExec: true }, + }), + ); + expect(rejected.success).toBe(false); }); it("test_supports_reusable_leases_compat_maps_to_reusable_leases", () => { diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 7151aa99530..b710c86c022 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -166,6 +166,7 @@ export const sandboxProviderCapabilitiesSchema = z.object({ persistentProcessSessions: z.boolean().optional(), independentControlCommands: z.boolean().optional(), incrementalSessionOutput: z.boolean().optional(), + concurrentSyncOperations: z.boolean().optional(), }).strict(); export type SandboxProviderCapabilitiesInput = z.infer; diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index d07e20f224e..af1febfcaa2 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -19,6 +19,9 @@ const SNAPSHOT: EffectiveSandboxCapabilities = { persistentProcessSessions: true, independentControlCommands: false, incrementalSessionOutput: false, + // Concurrent sync operations need BOTH sync verbs; this snapshot verified only + // inbound sync, so the opt-in stays off. + concurrentSyncOperations: false, }; // A snapshot that grants every capability. A test overrides one flag to prove @@ -30,6 +33,7 @@ const FULL_GRANT: EffectiveSandboxCapabilities = { persistentProcessSessions: true, independentControlCommands: true, incrementalSessionOutput: true, + concurrentSyncOperations: true, }; // Build a sandbox execution target with a fixed snapshot and a fixed @@ -185,6 +189,42 @@ describe("effective snapshot gates the sync decision", () => { }); }); +// The execution target carries the concurrent-sync opt-in on both the effective +// snapshot and the runner. The runner Boolean feeds the sync client, which then +// tells the orchestrator whether it may run sync operations concurrently. +describe("effective snapshot carries the concurrent-sync capability", () => { + beforeEach(() => { + mockResolveEnvironmentDriverConfigForRuntime.mockReset(); + }); + + it("carries the concurrent-sync opt-in on the snapshot and the runner", async () => { + const { target } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true }); + expect(target.effectiveCapabilities?.concurrentSyncOperations).toBe(true); + expect(target.runner?.allowConcurrentSyncOperations).toBe(true); + }); + + it("keeps the concurrent-sync opt-in off when the snapshot removes it", async () => { + const { target } = await buildSandboxTarget({ + snapshot: { ...FULL_GRANT, concurrentSyncOperations: false }, + supportsSync: true, + }); + expect(target.effectiveCapabilities?.concurrentSyncOperations).toBe(false); + expect(target.runner?.allowConcurrentSyncOperations).toBe(false); + }); + + it("keeps the concurrent-sync opt-in off when the resolution rejects", async () => { + // A rejected resolution carries no snapshot; it must not read as an open + // grant. The runner keeps the opt-in off. + const { target } = await buildSandboxTarget({ + snapshot: null, + supportsSync: true, + rejectResolution: true, + }); + expect(target.effectiveCapabilities).toBeUndefined(); + expect(target.runner?.allowConcurrentSyncOperations).toBe(false); + }); +}); + // Session-output streaming is decided downstream from the carried snapshot // alone (see `streamAgentSessionOutput` in `acpx-engine/execute.ts`): the bridge // streams only when the snapshot grants `incrementalSessionOutput`. That key is diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 0be33c0b4f2..902cc207f71 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -1019,17 +1019,18 @@ describe("buildReferencedProjectRunObservability", () => { failures: [ { projectId: "project-b", reason: "authorization" }, { projectId: "project-c", reason: "resolution" }, - { projectId: "project-d", reason: "staging" }, + { projectId: "project-d", reason: "staging", error: "extract failed: boom" }, ], }); // Requested is the synced count plus every dropped project, so the counts reconcile. expect(observability.referenced_projects_requested).toBe(4); expect(observability.referenced_projects_synced).toBe(1); + // A staging failure carries its error message; a failure without one omits the field. expect(observability.referenced_project_failures).toEqual([ { project_id: "project-b", reason: "authorization" }, { project_id: "project-c", reason: "resolution" }, - { project_id: "project-d", reason: "staging" }, + { project_id: "project-d", reason: "staging", error: "extract failed: boom" }, ]); }); diff --git a/server/src/__tests__/plugin-host-services-span.test.ts b/server/src/__tests__/plugin-host-services-span.test.ts index bb2a72bb568..cd3ad20cd66 100644 --- a/server/src/__tests__/plugin-host-services-span.test.ts +++ b/server/src/__tests__/plugin-host-services-span.test.ts @@ -311,4 +311,21 @@ describe("clampProviderSpanAttributes", () => { // A non-finite number yields no attribute; `exec.command` is not allowed. }); }); + + it("keeps the transfer direction for each closed-set value", () => { + expect(clampProviderSpanAttributes({ [A.transferDirection]: "inbound" })).toEqual({ + [A.transferDirection]: "inbound", + }); + expect(clampProviderSpanAttributes({ [A.transferDirection]: "outbound" })).toEqual({ + [A.transferDirection]: "outbound", + }); + }); + + it("drops a transfer direction outside the closed set or of the wrong type", () => { + // A free-form string, an empty string, and a non-string all yield no + // attribute, so the direction stays bounded and low-cardinality. + expect(clampProviderSpanAttributes({ [A.transferDirection]: "sideways" })).toEqual({}); + expect(clampProviderSpanAttributes({ [A.transferDirection]: "" })).toEqual({}); + expect(clampProviderSpanAttributes({ [A.transferDirection]: 1 })).toEqual({}); + }); }); diff --git a/server/src/__tests__/sandbox-capability-contract.test.ts b/server/src/__tests__/sandbox-capability-contract.test.ts index 16300432a21..571a8ddbbc8 100644 --- a/server/src/__tests__/sandbox-capability-contract.test.ts +++ b/server/src/__tests__/sandbox-capability-contract.test.ts @@ -290,6 +290,40 @@ describe("sandbox capability contract normalizer", () => { expect(effective.incrementalSessionOutput).toBe(false); }); + it("test_concurrent_sync_operations_is_opt_in_and_needs_both_sync_verbs", () => { + // Parallel bidirectional file sync is opt-in and direction-neutral. It needs + // both sync verbs, so a provider that verifies only one direction cannot get + // the capability. An absent declaration denies it even with both verbs. + const undeclared = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentSyncIn", "environmentSyncOut"], + declared: null, + }); + expect(undeclared.concurrentSyncOperations).toBe(false); + + // A positive declaration with both verified verbs resolves true. + const bothVerbs = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentSyncIn", "environmentSyncOut"], + declared: { concurrentSyncOperations: true }, + }); + expect(bothVerbs.concurrentSyncOperations).toBe(true); + + // Only the inbound verb: the outbound prerequisite is missing, so it resolves + // false. + const inOnly = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentSyncIn"], + declared: { concurrentSyncOperations: true }, + }); + expect(inOnly.concurrentSyncOperations).toBe(false); + + // Only the outbound verb: the inbound prerequisite is missing, so it resolves + // false. + const outOnly = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentSyncOut"], + declared: { concurrentSyncOperations: true }, + }); + expect(outOnly.concurrentSyncOperations).toBe(false); + }); + it("test_unknown_or_unavailable_verification_resolves_false", () => { const declaredAll = { reusableLeases: true, @@ -298,6 +332,7 @@ describe("sandbox capability contract normalizer", () => { persistentProcessSessions: true, independentControlCommands: true, incrementalSessionOutput: true, + concurrentSyncOperations: true, }; for (const verifiedMethods of [null, undefined, [] as string[]]) { diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 2689aaa2a8b..75738c5943a 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -317,6 +317,12 @@ export async function resolveEnvironmentExecutionTarget(input: { // here. The client falls back to the chunked upload path when this is // false. supportsSingleStreamStdinProgress: false, + // Carry the verified concurrent-sync opt-in to the sync client. The + // client copies it onto the native path and ignores it on the base64 + // fallback, which always permits concurrency. A null snapshot or a + // provider that never opted in keeps it false, so an unverified + // provider never permits concurrent sync operations. + allowConcurrentSyncOperations: effectiveCapabilities?.concurrentSyncOperations === true, execute: async (commandInput) => { // Record true start and stop timestamps around the provider await, // so the exec span and the result carry a real wall time. diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 117e071f473..db554c9dbd7 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -89,6 +89,7 @@ export const SANDBOX_CAPABILITY_KEYS = [ "persistentProcessSessions", "independentControlCommands", "incrementalSessionOutput", + "concurrentSyncOperations", ] as const; export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; @@ -104,6 +105,7 @@ export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; */ const SANDBOX_CAPABILITY_OPT_IN_KEYS: ReadonlySet = new Set([ "incrementalSessionOutput", + "concurrentSyncOperations", ]); /** @@ -134,6 +136,12 @@ const SANDBOX_CAPABILITY_OPT_IN_KEYS: ReadonlySet = new Se * provider tails the session log through it. The verified verb is necessary * but not sufficient: this key is opt-in, so the declaration is the real gate * (see {@link SANDBOX_CAPABILITY_OPT_IN_KEYS}). + * - `concurrentSyncOperations` requires BOTH sync verbs, because parallel + * bidirectional transfer runs an inbound and an outbound transfer at the same + * time. The two verbs are separate required groups, so a provider that + * verifies only one direction cannot get the capability. The verbs are + * necessary but not sufficient: this key is opt-in, so the declaration is the + * real gate (see {@link SANDBOX_CAPABILITY_OPT_IN_KEYS}). */ const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record = { // Reusable leases require ALL reuse verbs. Each verb is its own required @@ -147,6 +155,7 @@ const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record; } @@ -3231,6 +3239,9 @@ export function buildReferencedProjectRunObservability(input: { referenced_project_failures: input.failures.map((failure) => ({ project_id: failure.projectId, reason: failure.reason, + // Carry the error only when the layer produced one, so an authorization or resolution drop + // stays a two-field entry and a staging drop names its reason. + ...(failure.error !== undefined ? { error: failure.error } : {}), })), }; } @@ -16067,6 +16078,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) failures: referencedProjectStagingFailures.map((failure) => ({ projectId: failure.projectId, reason: "staging" as const, + error: failure.error, })), }); logger.info( diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index 3f975c18ef0..185524fc97e 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -545,6 +545,7 @@ const PROVIDER_SPAN_ATTR_ALLOWLIST: ReadonlySet = new Set([ SPAN_ATTRS.packWallMs, SPAN_ATTRS.transferWallMs, SPAN_ATTRS.transferGuardCount, + SPAN_ATTRS.transferDirection, ]); /** The subset of allowed keys that carry a finite number. */ @@ -557,11 +558,15 @@ const PROVIDER_SPAN_NUMERIC_ATTRS: ReadonlySet = new Set([ /** The closed value set for the `outcome` attribute. */ const KNOWN_SPAN_OUTCOMES: ReadonlySet = new Set(["ok", "skipped", "failed"]); +/** The closed value set for the `transfer.direction` attribute. */ +const KNOWN_TRANSFER_DIRECTIONS: ReadonlySet = new Set(["inbound", "outbound"]); + /** * Re-clamp the worker-sent attributes at the trust boundary. Drop every key that * is not on the allowlist. Re-map `provider` through `normalizeProviderFamily`, - * bound `outcome` to its closed set, and keep a numeric attribute only when it - * is a finite number. The result holds only bounded, low-cardinality values. + * bound `outcome` and `transfer.direction` each to its closed set, and keep a + * numeric attribute only when it is a finite number. The result holds only + * bounded, low-cardinality values. */ export function clampProviderSpanAttributes( raw: Record | undefined, @@ -578,6 +583,10 @@ export function clampProviderSpanAttributes( if (typeof value === "string" && KNOWN_SPAN_OUTCOMES.has(value)) clamped[key] = value; continue; } + if (key === SPAN_ATTRS.transferDirection) { + if (typeof value === "string" && KNOWN_TRANSFER_DIRECTIONS.has(value)) clamped[key] = value; + continue; + } if (PROVIDER_SPAN_NUMERIC_ATTRS.has(key)) { if (typeof value === "number" && Number.isFinite(value)) clamped[key] = value; continue; From e0e503e1bc11972560433aba40e47fc7641a5c08 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 19 Aug 2026 12:52:50 -0700 Subject: [PATCH 35/39] fix(server): make blockers-resolved wake dedup level-triggered (#11732) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip uses issue dependencies to pause work until blockers reach a ready state. > - A blocked issue with several blockers can miss its wake when the final blocker completes. > - The wake deduplication used a historical per-edge key, so an old completed wake hid the current ready state. > - This pull request adds a level-triggered key for the sorted set of blocker issue ids and uses one helper for all wake paths. > - The benefit is that the final blocker wake can repair a missed wake, while repeated reconciliation stays bounded. ## Linked Issues or Issue Description Refs #8009, #7853, and #6719. These public pull requests cover related dependency-wake and deduplication behavior. This pull request fixes a separate multi-blocker state-key gap. **What happened?** A blocked issue with multiple blockers received no `issue_blockers_resolved` wake when the final blocker completed. An earlier completed per-edge wake suppressed the wake for the current all-ready state. **Expected behavior** The final blocker completion must emit one wake for the current ready state. A later reconciliation pass must not emit a second wake for the same state. **Steps to reproduce** 1. Create a blocked issue with at least two blocker issues. 2. Complete one blocker and record its completed per-edge wake. 3. Complete the final blocker. 4. Run the route-time or reconciliation wake path. 5. Confirm that one level-triggered wake exists for the sorted blocker set. **Paperclip version or commit** `eed1e5cad91a37547e1b521232da04b9ddb316f0` **Deployment mode** Local dev from source. ## What Changed - Add a SHA-256 level-triggered idempotency key from the sorted blocker issue ids. - Share one deduplication helper across route-time, finalize-time, and periodic wake paths. - Treat state-key rows with idempotent statuses as duplicates. - Treat legacy per-edge rows as duplicates only while they remain in flight. - Record skipped route-time wakes without suppressing later finalize-time or periodic wakes. - Add regression coverage for a completed earlier-blocker wake and a second reconciliation pass. ## Verification - Run `npx vitest run server/src/__tests__/issue-dependency-wakeups-routes.test.ts`. - Run `npx vitest run server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts`. - Run `npx vitest run server/src/__tests__/heartbeat-dependency-scheduling.test.ts`. - Run `npx vitest run server/src/__tests__/issue-rewake-throttle.test.ts server/src/__tests__/recovery-stale-issue-lock-sweep.test.ts`. - Run `tsc --noEmit` on the touched files. ## Risks The change alters wake deduplication for dependency reconciliation. The new key uses the full sorted blocker set, so a change in that set permits a new wake. The regression tests cover the missed-final-blocker case and repeated reconciliation. ## Model Used OpenAI Codex, GPT-5, tool-use model with code execution and repository review support. ## 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 --- ...eartbeat-issue-liveness-escalation.test.ts | 106 +++++++++++++++- .../issue-dependency-wakeups-routes.test.ts | 7 +- server/src/routes/issues.ts | 22 ++-- .../src/services/issue-dependency-wakeups.ts | 119 ++++++++++++++---- server/src/services/recovery/service.ts | 24 ++-- 5 files changed, 222 insertions(+), 56 deletions(-) diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 0412cfaf85a..1f6d5b72a86 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -72,6 +72,7 @@ import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; import { runningProcesses } from "../adapters/index.ts"; import { DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS } from "../services/recovery/service.ts"; +import { buildIssueBlockersResolvedWakeStateKey } from "../services/issue-dependency-wakeups.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -484,7 +485,12 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { .then((rows) => rows[0] ?? null); expect(wake?.reason).toBe("issue_blockers_resolved"); - expect(wake?.idempotencyKey).toBe(`issue_blockers_resolved:${blockedIssueId}:${blockerIssueId}`); + expect(wake?.idempotencyKey).toBe( + buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: [blockerIssueId], + }), + ); expect(["queued", "claimed", "completed"]).toContain(wake?.status); const events = await db @@ -522,7 +528,12 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { .then((rows) => rows[0] ?? null); expect(wake?.reason).toBe("issue_blockers_resolved"); - expect(wake?.idempotencyKey).toBe(`issue_blockers_resolved:${blockedIssueId}:${blockerIssueId}`); + expect(wake?.idempotencyKey).toBe( + buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: [blockerIssueId], + }), + ); expect(["queued", "claimed", "completed"]).toContain(wake?.status); const events = await db @@ -564,14 +575,23 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { .then((rows) => rows[0] ?? null); expect(wake).toMatchObject({ reason: "issue_blockers_resolved", - idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${blockerIssueId}`, + idempotencyKey: buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: [blockerIssueId], + }), }); }); it("retries a resolved dependency wake when the prior wake was skipped as stale", async () => { const { companyId, agentId, blockedIssueId, blockerIssueId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); - const idempotencyKey = `issue_blockers_resolved:${blockedIssueId}:${blockerIssueId}`; + // The route-time wake writes the level-triggered state key. A skip records a + // `skipped` row with that key. `skipped` is not an in-flight status, so the + // backstop must still re-emit for the same ready state. + const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: [blockerIssueId], + }); await db.insert(agentWakeupRequests).values({ companyId, agentId, @@ -653,7 +673,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { .then((rows) => rows[0] ?? null); expect(wake).toMatchObject({ reason: "issue_blockers_resolved", - idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${blockerIssueId}`, + idempotencyKey: buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: [blockerIssueId], + }), }); }); @@ -716,6 +739,79 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { ); }); + it("heals a multi-blocker dependent when only a completed wake for an earlier blocker exists", async () => { + await enableAutoRecovery(); + const { companyId, agentId, blockedIssueId, blockerIssueId } = + await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); + const secondBlockerIssueId = randomUUID(); + await db.insert(issues).values({ + id: secondBlockerIssueId, + companyId, + title: "Earlier completed blocker", + status: "done", + priority: "medium", + issueNumber: 3, + identifier: "R-MULTI-3", + }); + await db.insert(issueRelations).values({ + companyId, + issueId: secondBlockerIssueId, + relatedIssueId: blockedIssueId, + type: "blocks", + }); + + // An earlier partial resolution left a `completed` per-edge wake. The bug was + // that this stale wake suppressed the wake for the current ready state. The + // level-triggered dedup keys on the full blocker set, so this completed wake + // no longer strands the dependent. + await db.insert(agentWakeupRequests).values({ + companyId, + agentId, + source: "automation", + triggerDetail: "system", + reason: "issue_blockers_resolved", + payload: { + issueId: blockedIssueId, + resolvedBlockerIssueId: secondBlockerIssueId, + }, + status: "completed", + finishedAt: new Date(), + idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${secondBlockerIssueId}`, + }); + + const readiness = await issueService(db).getDependencyReadiness(blockedIssueId); + expect(readiness.isDependencyReady).toBe(true); + + const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + + expect(result.dependencyWakesHealed).toBe(1); + expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]); + expect(result.dependencyWakeExistingSkipped).toBe(0); + + const stateKey = buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: blockedIssueId, + blockerIssueIds: readiness.blockerIssueIds, + }); + const healedWake = await db + .select({ status: agentWakeupRequests.status, idempotencyKey: agentWakeupRequests.idempotencyKey }) + .from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, stateKey))) + .then((rows) => rows[0] ?? null); + expect(healedWake).not.toBeNull(); + expect(["queued", "claimed", "completed"]).toContain(healedWake?.status); + + // A second reconciliation pass finds the state-key wake and stays bounded: + // it heals nothing more and never enqueues a second wake for the same state. + const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness(); + expect(secondPass.dependencyWakesHealed).toBe(0); + + const stateKeyWakes = await db + .select({ id: agentWakeupRequests.id }) + .from(agentWakeupRequests) + .where(and(eq(agentWakeupRequests.companyId, companyId), eq(agentWakeupRequests.idempotencyKey, stateKey))); + expect(stateKeyWakes).toHaveLength(1); + }); + it("counts null dependency wake returns as deferred instead of enqueue failures", async () => { await enableAutoRecovery(); const { companyId, agentId } = diff --git a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts index c8b76d429fb..a5d22dc1da1 100644 --- a/server/src/__tests__/issue-dependency-wakeups-routes.test.ts +++ b/server/src/__tests__/issue-dependency-wakeups-routes.test.ts @@ -3,7 +3,7 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockWakeup = vi.hoisted(() => vi.fn(async () => undefined)); -const mockFindExistingIssueBlockersResolvedWake = vi.hoisted(() => vi.fn(async () => null)); +const mockFindExistingIssueBlockersResolvedWakeForReadyState = vi.hoisted(() => vi.fn(async () => null)); const mockIssueService = vi.hoisted(() => ({ getAncestors: vi.fn(), getById: vi.fn(), @@ -98,7 +98,8 @@ vi.mock("../services/issue-dependency-wakeups.js", async () => { ); return { ...actual, - findExistingIssueBlockersResolvedWake: mockFindExistingIssueBlockersResolvedWake, + findExistingIssueBlockersResolvedWakeForReadyState: + mockFindExistingIssueBlockersResolvedWakeForReadyState, }; }); @@ -145,7 +146,7 @@ describe("issue dependency wakeups in issue routes", () => { vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); vi.clearAllMocks(); - mockFindExistingIssueBlockersResolvedWake.mockResolvedValue(null); + mockFindExistingIssueBlockersResolvedWakeForReadyState.mockResolvedValue(null); mockIssueService.getAncestors.mockResolvedValue([]); mockIssueService.getByIdForUpdate.mockImplementation(async () => mockIssueService.getById()); mockIssueService.getComment.mockResolvedValue(null); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4207e2d2c0b..566a973a7d8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -179,8 +179,8 @@ import { } from "../services/onboarding-greeting.js"; import { ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, - buildIssueBlockersResolvedWakeIdempotencyKey, - findExistingIssueBlockersResolvedWake, + buildIssueBlockersResolvedWakeStateKey, + findExistingIssueBlockersResolvedWakeForReadyState, } from "../services/issue-dependency-wakeups.js"; import { assertEnvironmentSelectionForCompany } from "./environment-selection.js"; import { @@ -10204,14 +10204,15 @@ export function issueRoutes( source: string; mutation: string; }) => { - const idempotencyKey = buildIssueBlockersResolvedWakeIdempotencyKey({ + const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({ dependentIssueId: input.dependentIssueId, - resolvedBlockerIssueId: input.resolvedBlockerIssueId, + blockerIssueIds: input.blockerIssueIds, }); try { - const existingWake = await findExistingIssueBlockersResolvedWake(db, { + const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, { companyId: issue.companyId, - idempotencyKey, + dependentIssueId: input.dependentIssueId, + blockerIssueIds: input.blockerIssueIds, }); if (existingWake) return; } catch (err) { @@ -12256,14 +12257,15 @@ export function issueRoutes( resolvedBlockerIssueId: string; blockerIssueIds: string[]; }) => { - const idempotencyKey = buildIssueBlockersResolvedWakeIdempotencyKey({ + const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({ dependentIssueId: input.dependentIssueId, - resolvedBlockerIssueId: input.resolvedBlockerIssueId, + blockerIssueIds: input.blockerIssueIds, }); try { - const existingWake = await findExistingIssueBlockersResolvedWake(db, { + const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, { companyId: currentIssue.companyId, - idempotencyKey, + dependentIssueId: input.dependentIssueId, + blockerIssueIds: input.blockerIssueIds, }); if (existingWake) return; } catch (err) { diff --git a/server/src/services/issue-dependency-wakeups.ts b/server/src/services/issue-dependency-wakeups.ts index 019a9a9cd52..bcd565a8b4e 100644 --- a/server/src/services/issue-dependency-wakeups.ts +++ b/server/src/services/issue-dependency-wakeups.ts @@ -1,9 +1,14 @@ -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; +import { createHash } from "node:crypto"; import type { Db } from "@paperclipai/db"; import { agentWakeupRequests } from "@paperclipai/db"; export const ISSUE_BLOCKERS_RESOLVED_WAKE_REASON = "issue_blockers_resolved"; +// A wake counts as "already delivered or in flight for the current ready state" +// for these statuses. The level-triggered state key uses this full set so that +// one wake for a ready state suppresses further wakes for the SAME state. This +// bounds reconciliation: after one wake, later passes find the completed row. const IDEMPOTENT_DEPENDENCY_WAKE_STATUSES = [ "queued", "deferred_issue_execution", @@ -11,6 +16,23 @@ const IDEMPOTENT_DEPENDENCY_WAKE_STATUSES = [ "completed", ] as const; +// A wake counts as "still in flight" for these statuses. The `completed` status +// is not in this set on purpose. Dependency readiness is level-triggered, so a +// historical completed per-edge wake must never suppress a new wake for the +// current ready state. The dedup uses this set only for the legacy per-edge key, +// to avoid a duplicate while an old-format wake is still queued or claimed. +const IN_FLIGHT_DEPENDENCY_WAKE_STATUSES = [ + "queued", + "deferred_issue_execution", + "claimed", +] as const; + +/** + * Legacy per-edge idempotency key. One key encodes a single resolved blocker + * edge `issue_blockers_resolved:{dependentIssueId}:{resolvedBlockerIssueId}`. + * The dedup keeps this format only to read wake rows written before the + * level-triggered state key existed. + */ export function buildIssueBlockersResolvedWakeIdempotencyKey(input: { dependentIssueId: string; resolvedBlockerIssueId: string; @@ -22,36 +44,82 @@ export function buildIssueBlockersResolvedWakeIdempotencyKey(input: { ].join(":"); } -export async function findExistingIssueBlockersResolvedWake( - db: Db, - input: { - companyId: string; - idempotencyKey: string; - }, -) { - return db - .select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status }) - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, input.companyId), - eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey), - inArray(agentWakeupRequests.status, [...IDEMPOTENT_DEPENDENCY_WAKE_STATUSES]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); +/** + * Level-triggered idempotency key. One key encodes the full set of blockers that + * defines the current dependency-ready state. Two wakes for the same ready state + * share the key. A wake for an earlier partial state has a different blocker set, + * so it produces a different key and never suppresses the current wake. All three + * emit paths (route-time, finalize-time, periodic backstop) use this key so they + * share one idempotency rule. + */ +export function buildIssueBlockersResolvedWakeStateKey(input: { + dependentIssueId: string; + blockerIssueIds: string[]; +}) { + const sortedBlockerIssueIds = [...new Set(input.blockerIssueIds.filter(Boolean))].sort(); + const digest = createHash("sha256") + .update(sortedBlockerIssueIds.join(",")) + .digest("hex") + .slice(0, 32); + return [ + ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, + "state", + input.dependentIssueId, + String(sortedBlockerIssueIds.length), + digest, + ].join(":"); } -export async function findExistingIssueBlockersResolvedWakeForAnyKey( +/** + * Find a wake that already covers the current dependency-ready state of the + * dependent issue. The check is level-triggered: + * + * - The state key matches a wake in any idempotent status (including + * `completed`). This suppresses a duplicate wake for the SAME ready state and + * bounds reconciliation. + * - Each legacy per-edge key matches only a wake that is still in flight + * (`queued`, `deferred_issue_execution`, `claimed`). This prevents a duplicate + * wake while an old-format wake is still pending after a deploy, but it never + * lets a historical completed per-edge wake strand the issue. + * + * Returns the first matching wake or `null`. + */ +export async function findExistingIssueBlockersResolvedWakeForReadyState( db: Db, input: { companyId: string; - idempotencyKeys: string[]; + dependentIssueId: string; + blockerIssueIds: string[]; }, ) { - const idempotencyKeys = [...new Set(input.idempotencyKeys.filter(Boolean))]; - if (idempotencyKeys.length === 0) return null; + const stateKey = buildIssueBlockersResolvedWakeStateKey({ + dependentIssueId: input.dependentIssueId, + blockerIssueIds: input.blockerIssueIds, + }); + const legacyKeys = [ + ...new Set( + input.blockerIssueIds + .filter(Boolean) + .map((resolvedBlockerIssueId) => + buildIssueBlockersResolvedWakeIdempotencyKey({ + dependentIssueId: input.dependentIssueId, + resolvedBlockerIssueId, + }), + ), + ), + ]; + + const stateMatch = and( + eq(agentWakeupRequests.idempotencyKey, stateKey), + inArray(agentWakeupRequests.status, [...IDEMPOTENT_DEPENDENCY_WAKE_STATUSES]), + ); + const legacyMatch = + legacyKeys.length > 0 + ? and( + inArray(agentWakeupRequests.idempotencyKey, legacyKeys), + inArray(agentWakeupRequests.status, [...IN_FLIGHT_DEPENDENCY_WAKE_STATUSES]), + ) + : null; return db .select({ @@ -63,8 +131,7 @@ export async function findExistingIssueBlockersResolvedWakeForAnyKey( .where( and( eq(agentWakeupRequests.companyId, input.companyId), - inArray(agentWakeupRequests.idempotencyKey, idempotencyKeys), - inArray(agentWakeupRequests.status, [...IDEMPOTENT_DEPENDENCY_WAKE_STATUSES]), + legacyMatch ? or(stateMatch, legacyMatch) : stateMatch, ), ) .limit(1) diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 193782770d6..ec31c026c86 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -48,8 +48,8 @@ import { } from "../issue-execution-policy.js"; import { ISSUE_BLOCKERS_RESOLVED_WAKE_REASON, - buildIssueBlockersResolvedWakeIdempotencyKey, - findExistingIssueBlockersResolvedWakeForAnyKey, + buildIssueBlockersResolvedWakeStateKey, + findExistingIssueBlockersResolvedWakeForReadyState, } from "../issue-dependency-wakeups.js"; import { evaluateAgentInvokabilityFromDb } from "../agent-invokability.js"; import { getRunLogStore } from "../run-log-store.js"; @@ -5248,19 +5248,19 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) continue; } - const idempotencyKeys = readiness.blockerIssueIds.map((blockerIssueId) => - buildIssueBlockersResolvedWakeIdempotencyKey({ - dependentIssueId: candidate.id, - resolvedBlockerIssueId: blockerIssueId, - }) - ); - const idempotencyKey = buildIssueBlockersResolvedWakeIdempotencyKey({ + // Level-triggered dedup: key on the full blocker set (the current ready + // state), not on any single resolved edge. An older completed per-edge + // wake for an earlier partial resolution has a different key, so it does + // not suppress this wake. The shared helper still suppresses a duplicate + // wake for the SAME ready state, which bounds reconciliation. + const idempotencyKey = buildIssueBlockersResolvedWakeStateKey({ dependentIssueId: candidate.id, - resolvedBlockerIssueId, + blockerIssueIds: readiness.blockerIssueIds, }); - const existingWake = await findExistingIssueBlockersResolvedWakeForAnyKey(db, { + const existingWake = await findExistingIssueBlockersResolvedWakeForReadyState(db, { companyId, - idempotencyKeys, + dependentIssueId: candidate.id, + blockerIssueIds: readiness.blockerIssueIds, }); if (existingWake) { result.existingWakeSkipped += 1; From 433b1eb099b9a71d8930dc40794748d6ba6b8f66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:53:30 -0700 Subject: [PATCH 36/39] build(deps): bump @aws-sdk/client-s3 from 3.1106.0 to 3.1111.0 (#11714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) from 3.1106.0 to 3.1111.0.
Release notes

Sourced from @​aws-sdk/client-s3's releases.

v3.1111.0

3.1111.0(2026-08-14)

Chores
Documentation Changes
  • client-redshift: Amazon Redshift now unlocks a locked admin user account and resets the failed-login counter when you update the admin password using the ModifyCluster API. This option is available only when account lockout security is enabled. (b93cb20c)
  • client-redshift-serverless: Amazon Redshift now unlocks a locked admin user account and resets the failed-login counter when you update the admin password using the UpdateNamespace API. This option is available only when account lockout security is enabled. (197b4aa6)
New Features
  • clients: update client endpoints as of 2026-08-14 (1e7a2806)
  • client-bedrock-agentcore-control: Adds AgentCore Payments support for CMK, Marketplace Subscriptions and QuickCreate (39108eb0)
  • client-sagemaker: Release support for g7.2xlarge, g7.4xlarge, g7.8xlarge, g7.12xlarge, g7.24xlarge, and g7.48xlarge instance types for SageMaker HyperPod (7198c193)
  • client-mwaa-serverless: Adds support for Consuming code for MWAA Serverless (e3edae27)
  • client-bedrock-agent-runtime: Adds CheckIngestedDocumentAcl and GetIngestedDocumentAcl APIs to Amazon Bedrock Knowledge Bases. Customers can verify user access to documents based on ingested ACLs and retrieve full ACL details including allow and deny entries, enabling validation of ACL ingestion without test retrievals. (e86c4204)
  • client-observabilityadmin: CloudWatch Logs centralization rules now support tag propagation. You can configure a TagPropagationConfiguration on your centralization rule to automatically sync resource tags from source to destination log groups, with configurable conflict resolution strategies. (c57d7a4c)
  • client-bedrock-agentcore: Add support for the Machine Payments Protocol (MPP) and x402 upto scheme payments protocol in Amazon Bedrock AgentCore Payments. Customers can now pay for MPP-gated resources and also pay services which requires upto scheme in x402 (7fdf457a)
  • client-glue: Added support for associating glossary terms with iterable form items, such as table columns. (4c2e27d1)

For list of updated packages, view updated-packages.md in assets-3.1111.0.zip

v3.1110.0

3.1110.0(2026-08-13)

New Features
  • client-auto-scaling: Amazon EC2 Auto Scaling now supports terminating multiple instances in a single TerminateInstanceInAutoScalingGroup call via the new InstanceIds parameter, returning an Activities list. LaunchInstances now returns IdempotentCallInProgressFault for duplicate client tokens. (ee707980)
  • client-cleanrooms: This release adds support for minimum aggregation thresholds and comparison controls to the Custom analysis rule type. (1f84f2ae)
  • client-codecommit: Added the GetBlobDifferences API operation, which returns line-level diffs between two blob versions without requiring a local clone. Returns structured hunks with context, additions, and deletions. Supports pagination for large diffs. (f1165c62)
  • client-securityagent: Add support for setting a maximum task-hour budget cap on penetration tests and code reviews, and for revalidating previously reported findings via a new REVALIDATION job type. (aba75d72)
  • client-connect: Adds the StartAssistantContact API to start chat contacts handled by an AI agent. Adds SegmentAttributes to StartWebRTCContact, and corrects its error response to now receive AccessDeniedException (previously returned as an internal server error due to a missing error declaration). (67f9b7bb)
  • client-acm: This change allows customers to update their existing email-validated certificates to use the DNS validation method. (71c194a4)

For list of updated packages, view updated-packages.md in assets-3.1110.0.zip

v3.1109.0

3.1109.0(2026-08-12)

Documentation Changes

... (truncated)

Changelog

Sourced from @​aws-sdk/client-s3's changelog.

3.1111.0 (2026-08-14)

Note: Version bump only for package @​aws-sdk/client-s3

3.1110.0 (2026-08-13)

Note: Version bump only for package @​aws-sdk/client-s3

3.1109.0 (2026-08-12)

Note: Version bump only for package @​aws-sdk/client-s3

3.1108.0 (2026-08-11)

Note: Version bump only for package @​aws-sdk/client-s3

3.1107.0 (2026-08-10)

Note: Version bump only for package @​aws-sdk/client-s3

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@aws-sdk/client-s3&package-manager=npm_and_yarn&previous-version=3.1106.0&new-version=3.1111.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 346 ++++++++++++++++++++++---------------------- server/package.json | 2 +- 2 files changed, 174 insertions(+), 174 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a7ea2ff61c..b24014d3009 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: server: dependencies: '@aws-sdk/client-s3': - specifier: ^3.1106.0 - version: 3.1106.0 + specifier: ^3.1111.0 + version: 3.1111.0 '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.1 @@ -1208,72 +1208,72 @@ packages: react: optional: true - '@aws-sdk/checksums@3.1000.27': - resolution: {integrity: sha512-insWOqKKNUrbN/dohEG7BJ0U5GkyqhjbMb/NHNaLUtq+7my2M8C4EnZZZoxMmXRqCC+P9dEr+KyJA2JGGzoKLg==} + '@aws-sdk/checksums@3.1000.28': + resolution: {integrity: sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1106.0': - resolution: {integrity: sha512-hUTlnyRRGlVdfvJLL3hCEnMm7CmunSzc/lxFVRX8g1fjJTMUVbyQCfhfMhp7dZ7JBftLU6OYresu3Hje4nvkJw==} + '@aws-sdk/client-s3@3.1111.0': + resolution: {integrity: sha512-VnLT6aSTN8tWl/NsXUysXNZor7wQBp9CRwufo7kt8cwGXvHLZ0S/cV1K9WFcREGboVYSo3NGQ3ZvU7LRidh2aQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.977.7': - resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==} + '@aws-sdk/core@3.977.8': + resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.68': - resolution: {integrity: sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==} + '@aws-sdk/credential-provider-env@3.972.69': + resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.70': - resolution: {integrity: sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==} + '@aws-sdk/credential-provider-http@3.972.71': + resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.973.13': - resolution: {integrity: sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==} + '@aws-sdk/credential-provider-ini@3.973.14': + resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.75': - resolution: {integrity: sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==} + '@aws-sdk/credential-provider-login@3.972.76': + resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.79': - resolution: {integrity: sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==} + '@aws-sdk/credential-provider-node@3.972.80': + resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.68': - resolution: {integrity: sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==} + '@aws-sdk/credential-provider-process@3.972.69': + resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.973.12': - resolution: {integrity: sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==} + '@aws-sdk/credential-provider-sso@3.973.13': + resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.74': - resolution: {integrity: sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==} + '@aws-sdk/credential-provider-web-identity@3.972.75': + resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.73': - resolution: {integrity: sha512-oy7sRA5HvHcAvkcKX6F8RI240jcOf3c8y/Gqjs9qemIibdKQqGBIi0uwa+47ZRYqGLpdEO28TQU4G73yUzo06Q==} + '@aws-sdk/middleware-sdk-s3@3.972.74': + resolution: {integrity: sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.42': - resolution: {integrity: sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==} + '@aws-sdk/nested-clients@3.997.43': + resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.44': - resolution: {integrity: sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==} + '@aws-sdk/signature-v4-multi-region@3.996.45': + resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1108.0': - resolution: {integrity: sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==} + '@aws-sdk/token-providers@3.1111.0': + resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.974.3': - resolution: {integrity: sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==} + '@aws-sdk/types@3.974.4': + resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.38': - resolution: {integrity: sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==} + '@aws-sdk/xml-builder@3.972.39': + resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==} engines: {node: '>=20.0.0'} '@aws/lambda-invoke-store@0.3.0': @@ -4866,28 +4866,28 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} - '@smithy/core@3.32.0': - resolution: {integrity: sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==} + '@smithy/core@3.33.2': + resolution: {integrity: sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.5.0': - resolution: {integrity: sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==} + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.7.0': - resolution: {integrity: sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==} + '@smithy/fetch-http-handler@5.7.2': + resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.10.0': - resolution: {integrity: sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==} + '@smithy/node-http-handler@4.11.2': + resolution: {integrity: sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.7.0': - resolution: {integrity: sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==} + '@smithy/signature-v4@5.7.2': + resolution: {integrity: sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==} engines: {node: '>=18.0.0'} - '@smithy/types@4.17.0': - resolution: {integrity: sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==} + '@smithy/types@4.17.2': + resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} engines: {node: '>=18.0.0'} '@standard-schema/spec@1.1.0': @@ -9106,167 +9106,167 @@ snapshots: '@types/react': 19.2.17 react: 19.2.7 - '@aws-sdk/checksums@3.1000.27': + '@aws-sdk/checksums@3.1000.28': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1106.0': + '@aws-sdk/client-s3@3.1111.0': dependencies: - '@aws-sdk/checksums': 3.1000.27 - '@aws-sdk/core': 3.977.7 - '@aws-sdk/credential-provider-node': 3.972.79 - '@aws-sdk/middleware-sdk-s3': 3.972.73 - '@aws-sdk/signature-v4-multi-region': 3.996.44 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/fetch-http-handler': 5.7.0 - '@smithy/node-http-handler': 4.10.0 - '@smithy/types': 4.17.0 + '@aws-sdk/checksums': 3.1000.28 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-node': 3.972.80 + '@aws-sdk/middleware-sdk-s3': 3.972.74 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/core@3.977.7': + '@aws-sdk/core@3.977.8': dependencies: - '@aws-sdk/types': 3.974.3 - '@aws-sdk/xml-builder': 3.972.38 + '@aws-sdk/types': 3.974.4 + '@aws-sdk/xml-builder': 3.972.39 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.32.0 - '@smithy/signature-v4': 5.7.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.33.2 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.68': + '@aws-sdk/credential-provider-env@3.972.69': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.70': + '@aws-sdk/credential-provider-http@3.972.71': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/fetch-http-handler': 5.7.0 - '@smithy/node-http-handler': 4.10.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.973.13': + '@aws-sdk/credential-provider-ini@3.973.14': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/credential-provider-env': 3.972.68 - '@aws-sdk/credential-provider-http': 3.972.70 - '@aws-sdk/credential-provider-login': 3.972.75 - '@aws-sdk/credential-provider-process': 3.972.68 - '@aws-sdk/credential-provider-sso': 3.973.12 - '@aws-sdk/credential-provider-web-identity': 3.972.74 - '@aws-sdk/nested-clients': 3.997.42 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/credential-provider-imds': 4.5.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-login': 3.972.76 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.75': + '@aws-sdk/credential-provider-login@3.972.76': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/nested-clients': 3.997.42 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.79': + '@aws-sdk/credential-provider-node@3.972.80': dependencies: - '@aws-sdk/credential-provider-env': 3.972.68 - '@aws-sdk/credential-provider-http': 3.972.70 - '@aws-sdk/credential-provider-ini': 3.973.13 - '@aws-sdk/credential-provider-process': 3.972.68 - '@aws-sdk/credential-provider-sso': 3.973.12 - '@aws-sdk/credential-provider-web-identity': 3.972.74 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/credential-provider-imds': 4.5.0 - '@smithy/types': 4.17.0 + '@aws-sdk/credential-provider-env': 3.972.69 + '@aws-sdk/credential-provider-http': 3.972.71 + '@aws-sdk/credential-provider-ini': 3.973.14 + '@aws-sdk/credential-provider-process': 3.972.69 + '@aws-sdk/credential-provider-sso': 3.973.13 + '@aws-sdk/credential-provider-web-identity': 3.972.75 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.68': + '@aws-sdk/credential-provider-process@3.972.69': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.973.12': + '@aws-sdk/credential-provider-sso@3.973.13': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/nested-clients': 3.997.42 - '@aws-sdk/token-providers': 3.1108.0 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/token-providers': 3.1111.0 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.74': + '@aws-sdk/credential-provider-web-identity@3.972.75': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/nested-clients': 3.997.42 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.73': + '@aws-sdk/middleware-sdk-s3@3.972.74': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/signature-v4-multi-region': 3.996.44 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.42': + '@aws-sdk/nested-clients@3.997.43': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/signature-v4-multi-region': 3.996.44 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/fetch-http-handler': 5.7.0 - '@smithy/node-http-handler': 4.10.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/signature-v4-multi-region': 3.996.45 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/fetch-http-handler': 5.7.2 + '@smithy/node-http-handler': 4.11.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.44': + '@aws-sdk/signature-v4-multi-region@3.996.45': dependencies: - '@aws-sdk/types': 3.974.3 - '@smithy/signature-v4': 5.7.0 - '@smithy/types': 4.17.0 + '@aws-sdk/types': 3.974.4 + '@smithy/signature-v4': 5.7.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1108.0': + '@aws-sdk/token-providers@3.1111.0': dependencies: - '@aws-sdk/core': 3.977.7 - '@aws-sdk/nested-clients': 3.997.42 - '@aws-sdk/types': 3.974.3 - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@aws-sdk/core': 3.977.8 + '@aws-sdk/nested-clients': 3.997.43 + '@aws-sdk/types': 3.974.4 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/types@3.974.3': + '@aws-sdk/types@3.974.4': dependencies: - '@smithy/types': 4.17.0 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.38': + '@aws-sdk/xml-builder@3.972.39': dependencies: - '@smithy/types': 4.17.0 + '@smithy/types': 4.17.2 tslib: 2.8.1 '@aws/lambda-invoke-store@0.3.0': {} @@ -12687,36 +12687,36 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} - '@smithy/core@3.32.0': + '@smithy/core@3.33.2': dependencies: - '@smithy/types': 4.17.0 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.5.0': + '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.7.0': + '@smithy/fetch-http-handler@5.7.2': dependencies: - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/node-http-handler@4.10.0': + '@smithy/node-http-handler@4.11.2': dependencies: - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/signature-v4@5.7.0': + '@smithy/signature-v4@5.7.2': dependencies: - '@smithy/core': 3.32.0 - '@smithy/types': 4.17.0 + '@smithy/core': 3.33.2 + '@smithy/types': 4.17.2 tslib: 2.8.1 - '@smithy/types@4.17.0': + '@smithy/types@4.17.2': dependencies: tslib: 2.8.1 diff --git a/server/package.json b/server/package.json index 67a07dca5eb..854e80cbc44 100644 --- a/server/package.json +++ b/server/package.json @@ -43,7 +43,7 @@ "typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1106.0", + "@aws-sdk/client-s3": "^3.1111.0", "@opentelemetry/api": "^1.9.0", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", From bd059a073dc6cbf2ac2f0295ecc5a0b8aab60e5a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:55:16 -0500 Subject: [PATCH 37/39] fix(workspaces): make managed runtimes reliable across restarts (#11740) 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 > - Execution workspaces need isolated databases, ports, and runtime services > - Concurrent workspaces could reuse ports or lose service ownership after a restart > - A markerless worktree also needed seed recovery, but normal markerless instances still needed to boot > - This pull request makes seed, port, and service ownership state explicit and recoverable > - It also checks live process and listener identity before it reclaims shared resources > - The benefit is reliable workspace startup, restart, adoption, and concurrent provisioning ## Linked Issues or Issue Description **What happened?** Managed workspaces could lose runtime service ownership after a control-plane restart. Concurrent worktrees could also reuse a port when their parent paths differed. A seed recovery change made every markerless instance resolve a worktree seed source, so normal instances without a source could not start. **Expected behavior** Paperclip must preserve healthy managed services across restarts. It must reserve unique ports across worktree parents. It must provision a registered markerless worktree, but it must skip seed work for a normal markerless instance. **Steps to reproduce** 1. Start two managed worktrees under different parent paths at the same time. 2. Restart the control plane while a managed service stays alive. 3. Start Paperclip with a config that has no seed markers and no registered worktree source. 4. Observe duplicate port selection, lost service adoption, or a seed-source startup error. **Paperclip version or commit** Current `master` plus the workspace runtime reliability changes in this pull request. **Deployment mode** Local development with managed execution workspaces and embedded Postgres. ## What Changed - Added a shared port registry with lease heartbeats, process identity checks, and live listener probes. - Reserved worktree ports across custom parent paths and repaired duplicate legacy assignments. - Preserved and adopted healthy managed services across control-plane restarts. - Reconciled guest bind modes and verified listener ownership before termination or reuse. - Provisioned registered markerless worktree databases and kept normal markerless instance startup as a no-op. - Added CLI, shared, server, and shell regression tests for seed, port, listener, restart, and adoption behavior. - Updated the worktree development documentation. ## Verification - `pnpm exec vitest run cli/src/__tests__/worktree.test.ts --reporter=verbose` — 63 tests passed. - `pnpm exec vitest run packages/shared/src/worktree-port-registry.test.ts --reporter=verbose` — 5 tests passed. - Focused runtime Vitest set — 199 tests passed across 37 suites. - `node --test scripts/__tests__/provision-worktree-self-heal.test.mjs` — 10 tests passed. - `git diff --check` passed. ## Risks - Port reservation now depends on lease and process identity data. The fallback listener probe prevents early reclamation when process metadata is incomplete. - Runtime adoption is stricter about bind and owner identity. The tests cover healthy adoption, stale records, PID reuse, and unrelated listeners. - Markerless seed detection now separates registered worktrees from normal instances. The tests cover both paths. - There are no database schema migrations. > 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. The serving snapshot and context-window size are not exposed. The agent used reasoning, repository tools, 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 Co-authored-by: Dev Agent --- cli/src/__tests__/worktree.test.ts | 398 ++++++++++++++++- cli/src/commands/worktree.ts | 357 +++++++++++---- doc/DEVELOPING.md | 15 +- .../runtime-exposure/loopback-bind.test.ts | 21 +- .../src/runtime-exposure/loopback-bind.ts | 6 +- .../shared/src/worktree-port-registry.test.ts | 147 +++++++ packages/shared/src/worktree-port-registry.ts | 411 ++++++++++++++++++ packages/shared/src/worktree-seed-source.ts | 66 +-- .../provision-worktree-self-heal.test.mjs | 108 ++++- scripts/provision-worktree-runtime.sh | 20 +- scripts/provision-worktree.sh | 51 +++ .../local-service-supervisor.test.ts | 155 +++++++ .../src/__tests__/workspace-runtime.test.ts | 306 ++++++++++++- server/src/__tests__/worktree-config.test.ts | 20 +- .../worktree-seed-server-spawn.test.ts | 229 ++++++++++ .../src/services/local-service-supervisor.ts | 172 +++++++- .../loopback-listener.test.ts | 2 +- .../runtime-exposure/loopback-listener.ts | 2 +- .../workspace-runtime-exposure.test.ts | 24 +- server/src/services/workspace-runtime.ts | 149 ++++--- server/src/worktree-config.ts | 92 +--- 21 files changed, 2406 insertions(+), 345 deletions(-) create mode 100644 packages/shared/src/worktree-port-registry.test.ts create mode 100644 packages/shared/src/worktree-port-registry.ts create mode 100644 server/src/__tests__/local-service-supervisor.test.ts create mode 100644 server/src/__tests__/worktree-seed-server-spawn.test.ts diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 8c2f5e3fd1e..1de56bb9bee 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -30,12 +30,14 @@ import { ensureEmbeddedPostgres, ensureWorktreeSeeded, formatWorktreeSeedFailureDiagnostic, + inspectLegacyWorktreeDatabase, markWorktreeSeedPending, pauseSeededScheduledRoutines, quarantineSeededWorktreeExecutionState, readWorktreeSeedManifest, readSourceAttachmentBody, rebindWorkspaceCwd, + requiresWorktreeSeedCredentialAccount, resolveSourceConfigPath, resolveWorktreeReseedSource, resolveWorktreeReseedTargetPaths, @@ -103,30 +105,36 @@ function mockVerifiedSeedResult() { }; } -async function seedValidWorktreeSource(connectionString: string) { +async function seedValidWorktreeSource( + connectionString: string, + options: { includeCredentialAccount?: boolean; userId?: string } = {}, +) { const db = createDb(connectionString); const companyId = randomUUID(); const issueId = randomUUID(); + const userId = options.userId ?? "user-existing"; const now = new Date(); await db.insert(authUsers).values({ - id: "user-existing", - email: "existing@paperclip.ing", - name: "Existing User", + id: userId, + email: userId === "local-board" ? "local@paperclip.local" : "existing@paperclip.ing", + name: userId === "local-board" ? "Board" : "Existing User", emailVerified: true, createdAt: now, updatedAt: now, }); - await db.insert(authAccounts).values({ - id: "credential-existing", - accountId: "existing@paperclip.ing", - providerId: "credential", - userId: "user-existing", - password: "fixture-password-hash", - createdAt: now, - updatedAt: now, - }); + if (options.includeCredentialAccount !== false) { + await db.insert(authAccounts).values({ + id: "credential-existing", + accountId: "existing@paperclip.ing", + providerId: "credential", + userId, + password: "fixture-password-hash", + createdAt: now, + updatedAt: now, + }); + } await db.insert(instanceUserRoles).values({ - userId: "user-existing", + userId, role: "instance_admin", }); await db.insert(companies).values({ @@ -138,7 +146,7 @@ async function seedValidWorktreeSource(connectionString: string) { await db.insert(companyMemberships).values({ companyId, principalType: "user", - principalId: "user-existing", + principalId: userId, status: "active", }); await db.insert(issues).values({ @@ -518,6 +526,22 @@ describe("worktree helpers", () => { .toBe("Seed failed during migrations."); }); + it("surfaces the missing credential artifact for authenticated seed validation", () => { + expect(formatWorktreeSeedFailureDiagnostic( + "source_validation", + new Error( + "No auth user has a non-empty credential account, instance-admin role, and active company membership. Authenticated worktree seeding requires a credential-backed instance administrator.", + ), + )).toBe( + "Seed validation could not find a credential-backed instance administrator with an active company membership. Authenticated instances must create or sign in an administrator before seeding.", + ); + }); + + it("requires credential accounts only for authenticated worktree seeds", () => { + expect(requiresWorktreeSeedCredentialAccount("local_trusted")).toBe(false); + expect(requiresWorktreeSeedCredentialAccount("authenticated")).toBe(true); + }); + it("rejects a source migration journal that diverges from the code journal", () => { expect(() => resolveWorktreeSeedMigrationRevision({ status: "upToDate", @@ -565,6 +589,42 @@ describe("worktree helpers", () => { }, "sourcePrefix")).toBe("0002_applied.sql"); }); + itEmbeddedPostgres("recognizes positive legacy database schema evidence", async () => { + const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-legacy-evidence-"); + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-legacy-config-")); + try { + const configPath = path.join(tempRoot, "config.json"); + const sourceConfig = buildSourceConfig(); + const config: PaperclipConfig = { + ...sourceConfig, + database: { + ...sourceConfig.database, + mode: "postgres", + connectionString: tempDb.connectionString, + backup: { + ...sourceConfig.database.backup, + enabled: false, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(tempRoot, "backups"), + }, + }, + }; + fs.writeFileSync(configPath, `${JSON.stringify(config)}\n`); + fs.writeFileSync( + path.join(tempRoot, ".env"), + `PAPERCLIP_INSTANCE_ID=legacy-target\nDATABASE_URL=${JSON.stringify(tempDb.connectionString)}\n`, + ); + + await expect(inspectLegacyWorktreeDatabase(configPath)).resolves.toEqual({ + migrationRevision: expect.stringMatching(/\.sql$/), + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + await tempDb.cleanup(); + } + }); + it("ensure-seeded seeds once and fast-exits on the verified manifest", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-")); try { @@ -636,6 +696,149 @@ describe("worktree helpers", () => { } }); + it("treats an unregistered markerless config as a normal non-worktree boot", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unregistered-markerless-")); + try { + const configPath = path.join(tempRoot, "config.json"); + fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`); + delete process.env.PAPERCLIP_WORKSPACE_BASE_CWD; + delete process.env.PAPERCLIP_PROJECT_WORKSPACE_ID; + delete process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID; + + const inspectLegacyDatabase = vi.fn(); + const seedDatabase = vi.fn(); + + await expect(ensureWorktreeSeeded( + { config: configPath }, + { inspectLegacyDatabase, seedDatabase }, + )).resolves.toEqual({ seeded: false, reason: "legacy_unmarked" }); + + expect(inspectLegacyDatabase).not.toHaveBeenCalled(); + expect(seedDatabase).not.toHaveBeenCalled(); + expect(readWorktreeSeedManifest(configPath)).toBeNull(); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("honors a legacy complete marker without resolving a seed source", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-complete-marker-")); + try { + const configPath = path.join(tempRoot, "config.json"); + fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`); + fs.writeFileSync(path.join(tempRoot, "seed-complete"), "complete\n"); + delete process.env.PAPERCLIP_WORKSPACE_BASE_CWD; + + const inspectLegacyDatabase = vi.fn(); + const seedDatabase = vi.fn(); + + await expect(ensureWorktreeSeeded( + { config: configPath }, + { inspectLegacyDatabase, seedDatabase }, + )).resolves.toEqual({ seeded: false, reason: "complete_marker" }); + + expect(inspectLegacyDatabase).not.toHaveBeenCalled(); + expect(seedDatabase).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("seeds a configured worktree with no seed markers when no legacy database is present", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-empty-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "worktree"); + const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json"); + const targetPaths = resolveWorktreeLocalPaths({ + cwd: targetRoot, + homeDir: path.join(tempRoot, "worktree-home"), + instanceId: "unmarked-empty-target", + }); + const sourceConfig = buildSourceConfig(); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths: targetPaths, + serverPort: 3194, + databasePort: 54994, + }); + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); + fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(targetRoot, ".paperclip", ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, + ); + const inspectLegacyDatabase = vi.fn().mockResolvedValue(null); + const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult()); + + await expect(ensureWorktreeSeeded( + { config: targetConfigPath, fromConfig: sourceConfigPath }, + { inspectLegacyDatabase, seedDatabase }, + )).resolves.toMatchObject({ seeded: true, reason: "seeded" }); + + expect(inspectLegacyDatabase).toHaveBeenCalledWith(targetConfigPath); + expect(seedDatabase).toHaveBeenCalledTimes(1); + expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({ + state: "verified", + phase: "complete", + migrationRevision: "0142_test.sql", + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("adopts a markerless legacy worktree only after validating its database schema", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-legacy-")); + try { + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "worktree"); + const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json"); + const targetPaths = resolveWorktreeLocalPaths({ + cwd: targetRoot, + homeDir: path.join(tempRoot, "worktree-home"), + instanceId: "unmarked-legacy-target", + }); + const sourceConfig = buildSourceConfig(); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths: targetPaths, + serverPort: 3193, + databasePort: 54993, + }); + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`); + fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n"); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(targetRoot, ".paperclip", ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`, + ); + const seedDatabase = vi.fn(); + + await expect(ensureWorktreeSeeded( + { config: targetConfigPath, fromConfig: sourceConfigPath }, + { + inspectLegacyDatabase: vi.fn().mockResolvedValue({ migrationRevision: "0141_legacy.sql" }), + seedDatabase, + }, + )).resolves.toEqual({ seeded: false, reason: "legacy_database" }); + + expect(seedDatabase).not.toHaveBeenCalled(); + expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({ + state: "verified", + phase: "complete", + migrationRevision: "0141_legacy.sql", + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("managed ensure-seeded derives a valid source from the registered base workspace", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-managed-seed-")); try { @@ -684,7 +887,7 @@ describe("worktree helpers", () => { }); it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)( - "managed ensure-seeded rejects a %s manifest source before lock or seed mutation", + "managed ensure-seeded re-derives a stale %s manifest source from registration", async (variant) => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `paperclip-worktree-managed-${variant}-`)); try { @@ -727,16 +930,31 @@ describe("worktree helpers", () => { JSON.stringify({ ...manifest, source: { ...manifest.source, instanceId: "foreign" } }), ); } - const seedDatabase = vi.fn(); + const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult()); await expect(ensureWorktreeSeeded({ config: targetConfigPath, registeredBaseWorkspaceCwd: baseRoot, registeredProjectWorkspaceId: "project-workspace-1", expectedCompanyId: "company-1", - }, { seedDatabase })).rejects.toThrow(); + }, { seedDatabase })).resolves.toMatchObject({ seeded: true, reason: "seeded" }); - expect(seedDatabase).not.toHaveBeenCalled(); + expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({ + sourceConfigPath: canonicalSource, + expectedCompanyId: "company-1", + })); + expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({ + source: { + configPath: canonicalSource, + instanceId: "registered-source", + }, + state: "verified", + diagnostics: expect.arrayContaining([ + expect.objectContaining({ + message: "Re-derived seed source diagnostics from the registered canonical source.", + }), + ]), + }); expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); @@ -1308,6 +1526,93 @@ describe("worktree helpers", () => { } }); + itEmbeddedPostgres( + "seeds a local-trusted implicit board user without a credential account", + async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-local-board-seed-")); + const worktreeRoot = path.join(tempRoot, "PAP-17696-local-board-seed"); + const sourceConfigDir = path.join(tempRoot, "source"); + const sourceConfigPath = path.join(sourceConfigDir, "config.json"); + const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key"); + const worktreeHome = path.join(tempRoot, ".paperclip-worktrees"); + const originalCwd = process.cwd(); + const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-local-board-source-"); + + try { + await seedValidWorktreeSource(sourceDb.connectionString, { + includeCredentialAccount: false, + userId: "local-board", + }); + fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true }); + fs.mkdirSync(worktreeRoot, { recursive: true }); + + const sourceConfig = buildSourceConfig(); + sourceConfig.database = { + ...sourceConfig.database, + mode: "postgres", + connectionString: sourceDb.connectionString, + }; + sourceConfig.server.deploymentMode = "local_trusted"; + sourceConfig.server.exposure = "private"; + sourceConfig.auth.baseUrlMode = "auto"; + delete sourceConfig.auth.publicBaseUrl; + sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath; + + fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8"); + fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8"); + + process.chdir(worktreeRoot); + await worktreeInitCommand({ + name: "PAP-17696-local-board-seed", + home: worktreeHome, + fromConfig: sourceConfigPath, + force: true, + }); + + const targetConfigPath = path.join(worktreeRoot, ".paperclip", "config.json"); + const targetConfig = JSON.parse(fs.readFileSync(targetConfigPath, "utf8")) as PaperclipConfig; + expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({ + state: "verified", + phase: "complete", + }); + + const { default: EmbeddedPostgres } = await import("embedded-postgres"); + const targetPg = new EmbeddedPostgres({ + databaseDir: targetConfig.database.embeddedPostgresDataDir, + user: "paperclip", + password: "paperclip", + port: targetConfig.database.embeddedPostgresPort, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + await targetPg.start(); + try { + const targetDb = createDb( + `postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`, + ); + const [seededLocalBoard] = await targetDb + .select({ id: authUsers.id }) + .from(authUsers) + .where(eq(authUsers.id, "local-board")); + const seededAccounts = await targetDb.select().from(authAccounts); + expect(seededLocalBoard?.id).toBe("local-board"); + expect(seededAccounts).toHaveLength(0); + await targetDb.$client.end({ timeout: 5 }); + } finally { + await targetPg.stop(); + } + } finally { + process.chdir(originalCwd); + await sourceDb.cleanup(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }, + 30_000, + ); + itEmbeddedPostgres( "seeds a lagging source whose migration application order differs from filename order", async () => { @@ -1535,6 +1840,61 @@ describe("worktree helpers", () => { } }); + it("reserves distinct ports for postgres-mode siblings under a custom worktree parent", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-custom-parent-")); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const customParentDir = path.join(tempRoot, "custom", "workspace-lanes"); + const firstWorktreeRoot = path.join(customParentDir, "lane-one"); + const secondWorktreeRoot = path.join(customParentDir, "lane-two"); + const missingSourceConfig = path.join(tempRoot, "missing", "config.json"); + const firstConfigPath = path.join(firstWorktreeRoot, ".paperclip", "config.json"); + const secondConfigPath = path.join(secondWorktreeRoot, ".paperclip", "config.json"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(firstWorktreeRoot, { recursive: true }); + fs.mkdirSync(secondWorktreeRoot, { recursive: true }); + + process.chdir(firstWorktreeRoot); + await worktreeInitCommand({ + name: "lane-one", + seed: false, + fromConfig: missingSourceConfig, + home: homeDir, + }); + + const firstConfig = JSON.parse(fs.readFileSync(firstConfigPath, "utf8")); + firstConfig.database = { + ...firstConfig.database, + mode: "postgres", + connectionString: "postgres://paperclip:paperclip@127.0.0.1:54330/paperclip", + }; + fs.writeFileSync(firstConfigPath, `${JSON.stringify(firstConfig, null, 2)}\n`, "utf8"); + + process.chdir(secondWorktreeRoot); + await worktreeInitCommand({ + name: "lane-two", + seed: false, + fromConfig: missingSourceConfig, + home: homeDir, + }); + + const secondConfig = JSON.parse(fs.readFileSync(secondConfigPath, "utf8")); + const registry = JSON.parse( + fs.readFileSync(path.join(homeDir, "worktree-port-reservations.json"), "utf8"), + ); + + expect(secondConfig.server.port).not.toBe(firstConfig.server.port); + expect(secondConfig.database.embeddedPostgresPort).not.toBe( + firstConfig.database.embeddedPostgresPort, + ); + expect(registry.configPaths).toEqual([firstConfigPath, secondConfigPath].sort()); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("defaults the seed source config to the current repo-local Paperclip config", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); const repoRoot = path.join(tempRoot, "repo"); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index e3dadf71e1f..6b7c76ed70f 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -22,7 +22,15 @@ import { Readable } from "node:stream"; import * as p from "@clack/prompts"; import pc from "picocolors"; import { and, eq, inArray, sql } from "drizzle-orm"; -import { resolveCanonicalWorktreeSeedSource } from "@paperclipai/shared/worktree-seed-source"; +import { + resolveCanonicalWorktreeSeedSource, + resolveRegisteredWorktreeSeedSource, +} from "@paperclipai/shared/worktree-seed-source"; +import { + readWorktreePortRegistry, + withWorktreePortRegistryLock, + writeWorktreePortRegistry, +} from "@paperclipai/shared/worktree-port-registry"; import { applyPendingMigrations, agents, @@ -239,10 +247,19 @@ type SeedWorktreeDatabase = typeof seedWorktreeDatabase; export type EnsureWorktreeSeededResult = { seeded: boolean; - reason: "seeded" | "verified_manifest" | "complete_marker" | "legacy_unmarked"; + reason: + | "seeded" + | "verified_manifest" + | "complete_marker" + | "legacy_unmarked" + | "legacy_database"; details?: SeedWorktreeDatabaseResult; }; +export type LegacyWorktreeDatabaseEvidence = { + migrationRevision: string; +}; + export type SeededWorktreeExecutionQuarantineSummary = { disabledTimerHeartbeats: number; resetRunningAgents: number; @@ -584,13 +601,24 @@ function resolveRepoManagedWorktreesRoot(cwd: string): string | null { return path.resolve(repoRoot, ".paperclip", "worktrees"); } -function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): { +function collectClaimedWorktreePorts( + homeDir: string, + currentInstanceId: string, + cwd: string, + registeredConfigPaths: Iterable = [], +): { serverPorts: Set; databasePorts: Set; } { const serverPorts = new Set(); const databasePorts = new Set(); const configPaths = new Set(); + for (const configPath of registeredConfigPaths) { + const resolvedConfigPath = path.resolve(configPath); + if (resolvedConfigPath !== path.resolve(cwd, ".paperclip", "config.json") && existsSync(resolvedConfigPath)) { + configPaths.add(resolvedConfigPath); + } + } const instancesDir = path.resolve(homeDir, "instances"); if (existsSync(instancesDir)) { for (const entry of readdirSync(instancesDir, { withFileTypes: true })) { @@ -620,8 +648,13 @@ function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, if (config?.server.port) { serverPorts.add(config.server.port); } - if (config?.database.mode === "embedded-postgres") { - databasePorts.add(config.database.embeddedPostgresPort); + const databasePort = config?.database.embeddedPostgresPort; + if ( + typeof databasePort === "number" && + Number.isInteger(databasePort) && + databasePort > 0 + ) { + databasePorts.add(databasePort); } } catch { // Ignore malformed sibling configs. @@ -1423,6 +1456,12 @@ type WorktreeSeedValidationExpectation = { representativeIssueId: string; }; +export function requiresWorktreeSeedCredentialAccount( + deploymentMode: PaperclipConfig["server"]["deploymentMode"], +): boolean { + return deploymentMode === "authenticated"; +} + export function resolveWorktreeSeedMigrationRevision( migrationState: Awaited>, requirement: "sourcePrefix" | "upToDate", @@ -1452,12 +1491,67 @@ export function resolveWorktreeSeedMigrationRevision( return migrationRevision; } +/** + * Markerless worktrees predate the versioned seed manifest. Adopt one only + * after proving that its configured database already has a compatible + * migration journal and the core Paperclip tables. The physical PG_VERSION + * check prevents this read-only probe from initializing a missing embedded + * database and then mistaking that empty cluster for legacy evidence. + */ +export async function inspectLegacyWorktreeDatabase( + configPath: string, +): Promise { + const config = readConfig(configPath); + if (!config) return null; + + const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath)); + let embeddedHandle: EmbeddedPostgresHandle | null = null; + let db: ReturnType | null = null; + try { + if (config.database.mode === "embedded-postgres") { + const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath); + if (!existsSync(path.join(dataDir, "PG_VERSION"))) return null; + embeddedHandle = await ensureEmbeddedPostgres(dataDir, config.database.embeddedPostgresPort); + } + + const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port); + const migrationRevision = resolveWorktreeSeedMigrationRevision( + await inspectMigrations(connectionString), + "sourcePrefix", + ); + db = createDb(connectionString); + await Promise.all([ + db.select({ id: authUsers.id }).from(authUsers).limit(1), + db.select({ id: companies.id }).from(companies).limit(1), + db.select({ id: issues.id }).from(issues).limit(1), + ]); + return { migrationRevision }; + } catch { + return null; + } finally { + await db?.$client?.end?.({ timeout: 5 }).catch(() => undefined); + if (embeddedHandle?.startedByThisProcess) { + await embeddedHandle.stop().catch(() => undefined); + } + } +} + async function inspectVerifiedSeedDatabase( connectionString: string, - expected?: WorktreeSeedValidationExpectation, - migrationRequirement: "sourcePrefix" | "upToDate" = "upToDate", - requiredCompanyId?: string, + options: { + deploymentMode: PaperclipConfig["server"]["deploymentMode"]; + expected?: WorktreeSeedValidationExpectation; + migrationRequirement?: "sourcePrefix" | "upToDate"; + requiredCompanyId?: string; + }, ): Promise<{ summary: WorktreeSeedValidationSummary; expectation: WorktreeSeedValidationExpectation }> { + const { + deploymentMode, + expected, + migrationRequirement = "upToDate", + requiredCompanyId, + } = options; + const requiresCredentialAccount = requiresWorktreeSeedCredentialAccount(deploymentMode); const migrationState = await inspectMigrations(connectionString); const migrationRevision = resolveWorktreeSeedMigrationRevision( migrationState, @@ -1499,13 +1593,9 @@ async function inspectVerifiedSeedDatabase( instanceUserRoles, and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")), ) - .innerJoin( + .leftJoin( authAccounts, - and( - eq(authAccounts.userId, authUsers.id), - sql`length(trim(${authAccounts.providerId})) > 0`, - sql`length(trim(${authAccounts.accountId})) > 0`, - ), + eq(authAccounts.userId, authUsers.id), ) .innerJoin( companyMemberships, @@ -1518,12 +1608,20 @@ async function inspectVerifiedSeedDatabase( .where(and( expected ? eq(authUsers.id, expected.adminUserId) : undefined, requiredCompanyId ? eq(companyMemberships.companyId, requiredCompanyId) : undefined, + requiresCredentialAccount + ? and( + sql`length(trim(${authAccounts.providerId})) > 0`, + sql`length(trim(${authAccounts.accountId})) > 0`, + ) + : undefined, )) .limit(1) .then((rows) => rows[0] ?? null); if (!admin) { throw new Error( - "No auth user has a non-empty credential account, instance-admin role, and active company membership.", + requiresCredentialAccount + ? "No auth user has a non-empty credential account, instance-admin role, and active company membership. Authenticated worktree seeding requires a credential-backed instance administrator." + : "No auth user has an instance-admin role and active company membership for local-trusted worktree seeding.", ); } @@ -1557,7 +1655,7 @@ async function inspectVerifiedSeedDatabase( }; if ( summary.authUserCount < 1 - || summary.credentialAccountCount < 1 + || (requiresCredentialAccount && summary.credentialAccountCount < 1) || summary.instanceAdminCount < 1 || summary.activeMembershipCount < 1 || summary.companyCount < 1 @@ -1613,9 +1711,11 @@ async function seedWorktreeDatabase(input: { input.onPhase?.("source_validation", "started"); const sourceValidation = await inspectVerifiedSeedDatabase( sourceConnectionString, - undefined, - "sourcePrefix", - input.expectedCompanyId, + { + deploymentMode: input.sourceConfig.server.deploymentMode, + migrationRequirement: "sourcePrefix", + requiredCompanyId: input.expectedCompanyId, + }, ); input.onPhase?.( "source_validation", @@ -1684,7 +1784,10 @@ async function seedWorktreeDatabase(input: { input.onPhase?.("post_restore_validation", "started"); const targetValidation = await inspectVerifiedSeedDatabase( targetConnectionString, - sourceValidation.expectation, + { + deploymentMode: input.targetConfig.server.deploymentMode, + expected: sourceValidation.expectation, + }, ); input.onPhase?.( "post_restore_validation", @@ -1729,6 +1832,13 @@ export function formatWorktreeSeedFailureDiagnostic( if (phase === "restore" && /Cannot seed target embedded PostgreSQL.+already running/i.test(message)) { return "Target embedded PostgreSQL is owned by a running worktree service. Stop that service and retry the seed."; } + if ( + /No auth user has a non-empty credential account, instance-admin role, and active company membership/i.test( + message, + ) + ) { + return "Seed validation could not find a credential-backed instance administrator with an active company membership. Authenticated instances must create or sign in an administrator before seeding."; + } return `Seed failed during ${phase}.`; } @@ -1841,6 +1951,7 @@ export function markWorktreeSeedPending(input: { targetInstanceId?: string; seedMode?: WorktreeSeedMode; now?: Date; + diagnosticMessage?: string; }): void { const markers = resolveWorktreeSeedMarkerPaths(input.configPath); const at = (input.now ?? new Date()).toISOString(); @@ -1859,7 +1970,14 @@ export function markWorktreeSeedPending(input: { attemptId: randomUUID(), startedAt: null, finishedAt: null, - diagnostics: [{ phase: "pending", status: "succeeded", at }], + diagnostics: [{ + phase: "pending", + status: "succeeded", + at, + ...(input.diagnosticMessage + ? { message: input.diagnosticMessage.slice(0, WORKTREE_SEED_DIAGNOSTIC_MESSAGE_LIMIT) } + : {}), + }], }); // New manifests are authoritative. Legacy files are removed so no caller can // mistake a stale binary marker for current verified seed state. @@ -2135,11 +2253,14 @@ async function runVerifiedWorktreeSeed(input: { export async function ensureWorktreeSeeded( opts: WorktreeEnsureSeededOptions = {}, - dependencies: { seedDatabase?: SeedWorktreeDatabase } = {}, + dependencies: { + seedDatabase?: SeedWorktreeDatabase; + inspectLegacyDatabase?: typeof inspectLegacyWorktreeDatabase; + } = {}, ): Promise { const configPath = resolveConfigPath(opts.config); const markers = resolveWorktreeSeedMarkerPaths(configPath); - let initialManifest = readWorktreeSeedManifest(configPath); + const initialManifest = readWorktreeSeedManifest(configPath); if (initialManifest?.state === "verified") { return { seeded: false, reason: "verified_manifest" }; } @@ -2149,14 +2270,6 @@ export async function ensureWorktreeSeeded( const legacyPending = !initialManifest && existsSync(markers.pending) ? readLegacyWorktreeSeedPendingMarker(markers.pending) : null; - if (!initialManifest && !legacyPending) { - if (existsSync(markers.lock)) { - const releaseExistingLock = await acquireWorktreeSeedLock(markers.lock); - await releaseExistingLock(); - } - return { seeded: false, reason: "legacy_unmarked" }; - } - const hasExplicitSource = Boolean(opts.fromConfig || opts.fromDataDir || opts.fromInstance); const explicitSourceConfigPath = hasExplicitSource ? resolveSourceConfigPath({ @@ -2168,6 +2281,13 @@ export async function ensureWorktreeSeeded( const registeredBaseWorkspaceCwd = opts.registeredBaseWorkspaceCwd ?? nonEmpty(process.env.PAPERCLIP_WORKSPACE_BASE_CWD) ?? null; + if (!initialManifest && !legacyPending && !hasExplicitSource && !registeredBaseWorkspaceCwd) { + if (existsSync(markers.lock)) { + const releaseExistingLock = await acquireWorktreeSeedLock(markers.lock); + await releaseExistingLock(); + } + return { seeded: false, reason: "legacy_unmarked" }; + } const registeredProjectWorkspaceId = opts.registeredProjectWorkspaceId ?? nonEmpty(process.env.PAPERCLIP_PROJECT_WORKSPACE_ID) ?? null; @@ -2183,26 +2303,21 @@ export async function ensureWorktreeSeeded( const targetRoot = path.dirname(path.dirname(configPath)); const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot }); - const resolveSeedSource = (manifest: WorktreeSeedManifest | null) => { - const diagnosticSource = manifest?.source ?? (legacyPending - ? { - configPath: legacyPending.sourceConfigPath, - instanceId: resolveSeedInstanceId(legacyPending.sourceConfigPath), - } - : null); - return resolveCanonicalWorktreeSeedSource({ - registeredBaseWorkspaceCwd, - explicitSourceConfigPath, - targetConfigPath: configPath, - expectedTargetInstanceId: targetPaths.instanceId, - manifestSource: diagnosticSource, - manifestTargetInstanceId: manifest?.targetInstanceId ?? targetPaths.instanceId, - }); - }; + const registeredSeedSource = resolveRegisteredWorktreeSeedSource({ + registeredBaseWorkspaceCwd, + explicitSourceConfigPath, + targetConfigPath: configPath, + expectedTargetInstanceId: targetPaths.instanceId, + }); - // Fail before creating the seed lock or rewriting a legacy marker. The manifest - // is agent-writable diagnostic evidence and can never select this source. - let canonicalSource = resolveSeedSource(initialManifest); + if (initialManifest && initialManifest.targetInstanceId !== registeredSeedSource.targetInstanceId) { + throw new Error("Worktree seed manifest target instance does not match the registered target instance."); + } + + // Resolve all authority-bearing paths before creating the lock. The manifest is + // agent-writable diagnostic evidence and never selects the source. A stale source + // diagnostic is replaced under the lock from this server/operator registration. + let canonicalSource = registeredSeedSource; mkdirSync(path.dirname(markers.lock), { recursive: true }); const releaseLock = await acquireWorktreeSeedLock(markers.lock); try { @@ -2213,9 +2328,6 @@ export async function ensureWorktreeSeeded( if (manifest?.state === "verified") { return { seeded: false, reason: "verified_manifest" }; } - if (!manifest && existsSync(markers.complete)) { - return { seeded: false, reason: "complete_marker" }; - } if (!manifest && existsSync(markers.pending)) { const currentLegacyPending = readLegacyWorktreeSeedPendingMarker(markers.pending); if (currentLegacyPending.sourceConfigPath !== legacyPending?.sourceConfigPath) { @@ -2223,18 +2335,71 @@ export async function ensureWorktreeSeeded( } markWorktreeSeedPending({ configPath, - sourceConfigPath: canonicalSource.configPath, + sourceConfigPath: registeredSeedSource.configPath, targetInstanceId: targetPaths.instanceId, seedMode: "minimal", + diagnosticMessage: "Re-derived seed source diagnostics from the registered canonical source.", }); manifest = readWorktreeSeedManifest(configPath); } if (!manifest) { - // Worktrees created before lazy seeding shipped were seeded eagerly and - // have neither marker. Preserve that compatibility without re-cloning. - return { seeded: false, reason: "legacy_unmarked" }; + const legacyEvidence = await ( + dependencies.inspectLegacyDatabase ?? inspectLegacyWorktreeDatabase + )(configPath); + if (legacyEvidence) { + markWorktreeSeedPending({ + configPath, + sourceConfigPath: registeredSeedSource.configPath, + targetInstanceId: targetPaths.instanceId, + seedMode: "minimal", + diagnosticMessage: "Validated existing legacy worktree database schema before adoption.", + }); + startWorktreeSeedAttempt(configPath); + updateWorktreeSeedManifest({ + configPath, + phase: "complete", + status: "succeeded", + state: "verified", + snapshotAt: new Date().toISOString(), + migrationRevision: legacyEvidence.migrationRevision, + message: "Adopted an existing legacy worktree database after validating its migration journal and core schema.", + }); + return { seeded: false, reason: "legacy_database" }; + } + + markWorktreeSeedPending({ + configPath, + sourceConfigPath: registeredSeedSource.configPath, + targetInstanceId: targetPaths.instanceId, + seedMode: "minimal", + diagnosticMessage: "No verified seed or compatible legacy database was found; provisioning is required.", + }); + manifest = readWorktreeSeedManifest(configPath); + if (!manifest) { + throw new Error("Failed to create a pending worktree seed manifest."); + } } - canonicalSource = resolveSeedSource(manifest); + if ( + manifest.source.configPath !== registeredSeedSource.configPath + || manifest.source.instanceId !== registeredSeedSource.instanceId + ) { + markWorktreeSeedPending({ + configPath, + sourceConfigPath: registeredSeedSource.configPath, + targetInstanceId: manifest.targetInstanceId, + seedMode: manifest.seedMode, + diagnosticMessage: "Re-derived seed source diagnostics from the registered canonical source.", + }); + manifest = readWorktreeSeedManifest(configPath)!; + } + canonicalSource = resolveCanonicalWorktreeSeedSource({ + registeredBaseWorkspaceCwd, + explicitSourceConfigPath, + targetConfigPath: configPath, + expectedTargetInstanceId: targetPaths.instanceId, + manifestSource: manifest.source, + manifestTargetInstanceId: manifest.targetInstanceId, + }); const sourceConfigPath = canonicalSource.configPath; const sourceConfig = readConfig(sourceConfigPath); @@ -2313,22 +2478,48 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { rmSync(paths.instanceRoot, { recursive: true, force: true }); } - const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd); - const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); - const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts); - const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); - const databasePort = await findAvailablePort( - preferredDbPort, - new Set([...claimedPorts.databasePorts, serverPort]), - ); - const targetConfig = buildWorktreeConfig({ - sourceConfig, - paths, - serverPort, - databasePort, - }); + const { serverPort, databasePort, targetConfig } = await withWorktreePortRegistryLock( + paths.homeDir, + async () => { + const registeredConfigPaths = readWorktreePortRegistry(paths.homeDir); + const claimedPorts = collectClaimedWorktreePorts( + paths.homeDir, + paths.instanceId, + paths.cwd, + registeredConfigPaths, + ); + const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); + const selectedServerPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts); + const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); + const selectedDatabasePort = await findAvailablePort( + preferredDbPort, + new Set([...claimedPorts.databasePorts, selectedServerPort]), + ); + const selectedConfig = buildWorktreeConfig({ + sourceConfig, + paths, + serverPort: selectedServerPort, + databasePort: selectedDatabasePort, + }); - writeConfig(targetConfig, paths.configPath); + try { + writeConfig(selectedConfig, paths.configPath); + writeWorktreePortRegistry(paths.homeDir, [ + ...registeredConfigPaths, + paths.configPath, + ]); + } catch (error) { + rmSync(paths.configPath, { force: true }); + throw error; + } + + return { + serverPort: selectedServerPort, + databasePort: selectedDatabasePort, + targetConfig: selectedConfig, + }; + }, + ); markWorktreeSeedPending({ configPath: paths.configPath, sourceConfigPath, @@ -2436,21 +2627,17 @@ export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOpti printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree ensure-seeded "))); - const markers = resolveWorktreeSeedMarkerPaths(resolveConfigPath(opts.config)); - if (existsSync(markers.complete) || !existsSync(markers.pending)) { - const result = await ensureWorktreeSeeded(opts); - const reason = result.reason === "complete_marker" - ? "Seed-complete marker already present." - : "No seed-pending marker found; treating this legacy worktree as already seeded."; - p.outro(pc.green(reason)); - return; - } - const spinner = p.spinner(); - spinner.start("Seeding isolated worktree database from source instance (minimal)..."); + spinner.start("Checking isolated worktree database seed state..."); try { const result = await ensureWorktreeSeeded(opts); - spinner.stop("Seeded isolated worktree database (minimal)."); + if (result.seeded) { + spinner.stop("Seeded isolated worktree database (minimal)."); + } else if (result.reason === "legacy_database") { + spinner.stop("Validated and adopted an existing legacy worktree database."); + } else { + spinner.stop("Worktree database already has a verified seed manifest."); + } if (result.details) { p.log.message(pc.dim(`Seed snapshot: ${result.details.backupSummary}`)); p.log.message( diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 32791b0b216..53a787da102 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -327,6 +327,8 @@ Every local install keeps runtime state directly under the selected instance roo storage/ # local_disk uploads backups/ # automatic DB backups logs/ + runtime-services/ # managed local-service registry + runtime-service-logs/ # append-only managed-service stdout/stderr secrets/master.key # local_encrypted master key workspaces// # default agent workspaces projects/ # project execution workspaces @@ -485,15 +487,15 @@ The default `worktree init` still seeds eagerly. A lean worktree (created withou - `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source exclusively from the control-plane-provided base project workspace; manual worktrees must pass `--from-config`. - `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config `. -- 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 manifest is not verified. An explicitly configured runtime provision command always takes precedence. +- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`, so the isolated config and pending manifest cannot be silently skipped. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence. - The built-in deferred seed is recorded as its own terminal `workspace_seed` operation. A zero exit code is not enough for success: the operation succeeds only when `.paperclip/seed-manifest.json` contains complete verified evidence; failed, missing, or malformed manifests produce a failed operation with the seed phase in metadata. -- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned). +- Worktrees created before lazy seeding shipped may have neither marker. Paperclip adopts them only after their configured database proves a compatible migration journal and the core Paperclip schema; otherwise managed startup creates a pending manifest and performs the normal verified seed. Manual markerless worktrees must provide `--from-config` so the source remains explicit. -Both `minimal` and `full` modes use the same terminal data-validation contract. Source validation accepts a migration journal that is a prefix of the checkout's journal and records the source revision in seed diagnostics; it rejects a source that is ahead of the checkout because that would require a downgrade. After restore, Paperclip applies pending migrations and requires the target journal to be current. Both validations also read a credential-backed auth user, instance administrator role, active company membership, and representative cloned company/issue pair. Restore, migrations, execution quarantine, routine pausing, workspace rebinding, and post-restore validation all run under the seed lock. An interruption leaves the exact active phase in terminal `failed` state; it cannot produce readiness evidence. +Both `minimal` and `full` modes use the same terminal data-validation contract. Source validation accepts a migration journal that is a prefix of the checkout's journal and records the source revision in seed diagnostics; it rejects a source that is ahead of the checkout because that would require a downgrade. After restore, Paperclip applies pending migrations and requires the target journal to be current. Both validations also read an auth user with an instance administrator role, active company membership, and representative cloned company/issue pair. Authenticated instances additionally require that administrator to have a non-empty credential account. `local_trusted` instances accept the implicit local Board user without an account row because that mode intentionally has no human login flow. Restore, migrations, execution quarantine, routine pausing, workspace rebinding, and post-restore validation all run under the seed lock. An interruption leaves the exact active phase in terminal `failed` state; it cannot produce readiness evidence. The seed process must own the target embedded PostgreSQL lifecycle for that entire sequence. It refuses to restore into a target postmaster that is already running, suppresses the embedded provider's process-global exit hooks, and stops its owned target only after validation or failure cleanup. A shutdown detected during restore is recorded as a target-database shutdown diagnostic rather than a generic restore failure. -The seed manifest never grants source-path authority. Its source path and instance are diagnostic assertions that must exactly match the realpath-canonical registered source before any lock, backup, service stop, spawn, or database mutation. Missing registration, sibling or foreign paths, symlink aliases, source/target identity collisions, and company mismatches fail closed. +The seed manifest never grants source-path authority. Its source path and instance are diagnostic assertions derived from the realpath-canonical registered source. Deferred seeding resolves registration independently before taking the seed lock; under that lock it replaces stale source diagnostics from the registered value and then revalidates the manifest before any backup, service stop, spawn, or database mutation. Missing registration, invalid registered paths, source/target identity collisions, target-instance mismatches, and company mismatches fail closed. **Unverified-seed guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose manifest is pending, running, failed, malformed, or missing required verification evidence and points you at the fix: @@ -695,6 +697,11 @@ Heavier setup that is only needed by a managed runtime service can use `workspac Managed runtime control actions (`start`, `stop`, `restart`, and job `run`) are mutually exclusive per execution workspace. An overlapping control is rejected with `409 workspace_runtime_control_in_progress` instead of racing the active operation, and authorization is still checked first, so the conflict never widens who may control a workspace. +Local managed runtime services write stdout and stderr directly to append-only +files under the instance's `runtime-service-logs/` directory. The child inherits +the file descriptors rather than supervisor-owned pipes, so request-logging +servers remain responsive and adoptable when the control plane restarts. + Every managed control reaches a terminal operation state. Each one stamps the owning server process and pid on its `workspace_operations` row and heartbeats while it runs, and each one carries a wall-clock ceiling (30 minutes for lifecycle controls, 4 hours for workspace jobs) so a hung provider or listener fails the operation rather than leaving it active. When a start fails part-way, Paperclip tears the workspace's runtime services down through the ordinary stop path and records a stopped desired state, so the lane is retryable and a startup reconcile will not resurrect a service that never came up. Recovery of stranded controls is bounded and cannot steal a live operation. A `running` control is only terminalized when its owning process is gone, when the owning request in this process no longer exists, or after 60 seconds without a heartbeat; the terminalizing write is a compare-and-swap on `updated_at`, so an owner that heartbeats concurrently keeps its operation. Recovery runs on server startup and before each managed control, appends reconciliation evidence to the workspace-operation log, and stays inside the requested workspace's scope. diff --git a/packages/shared/src/runtime-exposure/loopback-bind.test.ts b/packages/shared/src/runtime-exposure/loopback-bind.test.ts index 2f5d188276c..293e1ef3522 100644 --- a/packages/shared/src/runtime-exposure/loopback-bind.test.ts +++ b/packages/shared/src/runtime-exposure/loopback-bind.test.ts @@ -13,16 +13,16 @@ describe("forceLoopbackBindInCommand", () => { // PAP-17256 reproduction: this is verbatim what `workspace_runtime_services` // recorded for every lane the broker denied. expect(forceLoopbackBindInCommand("pnpm dev --bind lan")).toBe( - "pnpm dev --bind custom --bind-host 127.0.0.1", + "pnpm dev --bind loopback", ); }); it("replaces an existing loopback-adjacent bind rather than duplicating it", () => { expect(forceLoopbackBindInCommand("pnpm dev --bind tailnet")).toBe( - "pnpm dev --bind custom --bind-host 127.0.0.1", + "pnpm dev --bind loopback", ); expect(forceLoopbackBindInCommand("pnpm dev --bind custom --bind-host 10.0.0.4")).toBe( - "pnpm dev --bind custom --bind-host 127.0.0.1", + "pnpm dev --bind loopback", ); }); @@ -33,7 +33,7 @@ describe("forceLoopbackBindInCommand", () => { it("handles the =-separated spelling", () => { expect(forceLoopbackBindInCommand("pnpm dev --bind=lan --bind-host=0.0.0.0")).toBe( - "pnpm dev --bind custom --bind-host 127.0.0.1", + "pnpm dev --bind loopback", ); }); @@ -41,7 +41,7 @@ describe("forceLoopbackBindInCommand", () => { // A bare `pnpm dev` lets an old guest runner infer its bind from HOST, so the // flags must be added rather than assumed. expect(forceLoopbackBindInCommand("pnpm dev")).toBe( - "pnpm dev --bind custom --bind-host 127.0.0.1", + "pnpm dev --bind loopback", ); }); @@ -49,19 +49,19 @@ describe("forceLoopbackBindInCommand", () => { // `isPaperclipDevRuntimeService` matches `--tailscale-auth` as a substring; // an explicit `--bind` already beats the alias in every dev-runner version. expect(forceLoopbackBindInCommand("pnpm dev:once --tailscale-auth")).toBe( - "pnpm dev:once --tailscale-auth --bind custom --bind-host 127.0.0.1", + "pnpm dev:once --tailscale-auth --bind loopback", ); }); it("never strips a value that is actually the next flag", () => { expect(forceLoopbackBindInCommand("pnpm dev --bind --verbose")).toBe( - "pnpm dev --bind --verbose --bind custom --bind-host 127.0.0.1", + "pnpm dev --bind --verbose --bind loopback", ); }); it("does not touch a --bind occurrence that is not a flag boundary", () => { expect(forceLoopbackBindInCommand("pnpm dev --no--bind lan")).toBe( - "pnpm dev --no--bind lan --bind custom --bind-host 127.0.0.1", + "pnpm dev --no--bind lan --bind loopback", ); }); @@ -74,8 +74,9 @@ describe("forceLoopbackBindInCommand", () => { expect(forceLoopbackBindInCommand(fixture)).toBe(fixture); }); - it("uses the loopback host constant", () => { - expect(forceLoopbackBindInCommand("pnpm dev")).toContain(RUNTIME_EXPOSURE_BIND_HOST); + it("uses the loopback preset without turning it into a custom bind", () => { + expect(forceLoopbackBindInCommand("pnpm dev")).toBe("pnpm dev --bind loopback"); + expect(RUNTIME_EXPOSURE_BIND_HOST).toBe("127.0.0.1"); }); }); diff --git a/packages/shared/src/runtime-exposure/loopback-bind.ts b/packages/shared/src/runtime-exposure/loopback-bind.ts index 61ff7e4c179..696dcbc1fc8 100644 --- a/packages/shared/src/runtime-exposure/loopback-bind.ts +++ b/packages/shared/src/runtime-exposure/loopback-bind.ts @@ -18,8 +18,8 @@ * Pure string functions only — no I/O, no process state. */ -/** The only address an exposed managed runtime may bind. */ -export const RUNTIME_EXPOSURE_BIND_MODE = "custom"; +/** The only bind preset an exposed managed runtime may use. */ +export const RUNTIME_EXPOSURE_BIND_MODE = "loopback"; export const RUNTIME_EXPOSURE_BIND_HOST = "127.0.0.1"; /** @@ -72,7 +72,7 @@ export function forceLoopbackBindInCommand(command: string): string { if (!isPaperclipDevRunnerCommand(command)) return command; const stripped = command.replace(BIND_SELECTING_ARG, "").trim(); if (stripped.length === 0) return command; - return `${stripped} --bind ${RUNTIME_EXPOSURE_BIND_MODE} --bind-host ${RUNTIME_EXPOSURE_BIND_HOST}`; + return `${stripped} --bind ${RUNTIME_EXPOSURE_BIND_MODE}`; } /** diff --git a/packages/shared/src/worktree-port-registry.test.ts b/packages/shared/src/worktree-port-registry.test.ts new file mode 100644 index 00000000000..28c6c869d8e --- /dev/null +++ b/packages/shared/src/worktree-port-registry.test.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { + withWorktreePortRegistryLock, + withWorktreePortRegistryLockSync, +} from "./worktree-port-registry.js"; + +const temporaryRoots: string[] = []; + +function makeTemporaryRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-port-registry-lock-")); + temporaryRoots.push(root); + return root; +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +describe("worktree port registry lock", () => { + it("does not reclaim a stale lock while its fallback ownership probe responds", async () => { + const homeDir = makeTemporaryRoot(); + const lockPath = path.join(homeDir, ".worktree-port-reservations.lock"); + const firstEntered = deferred(); + const releaseFirst = deferred(); + let secondEntered = false; + + const first = withWorktreePortRegistryLock(homeDir, async () => { + fs.renameSync(path.join(lockPath, "owner.json"), path.join(lockPath, "owner.unavailable.json")); + const backupOwnerPath = path.join(lockPath, "owner.backup.json"); + const owner = JSON.parse(fs.readFileSync(backupOwnerPath, "utf8")); + fs.writeFileSync(backupOwnerPath, `${JSON.stringify({ + ...owner, + processIdentity: "unavailable-process-identity", + })}\n`); + const oldTimestamp = new Date(Date.now() - 10_000); + fs.utimesSync(lockPath, oldTimestamp, oldTimestamp); + firstEntered.resolve(); + await releaseFirst.promise; + }); + await firstEntered.promise; + + expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeGreaterThan(5_000); + + const second = withWorktreePortRegistryLock(homeDir, async () => { + secondEntered = true; + }); + await delay(100); + + expect(secondEntered).toBe(false); + releaseFirst.resolve(); + await Promise.all([first, second]); + expect(secondEntered).toBe(true); + }, 10_000); + + it("refreshes the lease throughout an async critical section", async () => { + const homeDir = makeTemporaryRoot(); + const lockPath = path.join(homeDir, ".worktree-port-reservations.lock"); + + await withWorktreePortRegistryLock(homeDir, async () => { + await delay(5_250); + expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeLessThan(2_000); + }); + + expect(fs.existsSync(lockPath)).toBe(false); + }, 10_000); + + it("reclaims an old lock after its owner process exits", async () => { + const homeDir = makeTemporaryRoot(); + const lockPath = path.join(homeDir, ".worktree-port-reservations.lock"); + fs.mkdirSync(lockPath); + fs.writeFileSync( + path.join(lockPath, "owner.json"), + `${JSON.stringify({ + version: 1, + pid: 2_147_483_647, + processIdentity: "dead-process", + probePort: 1, + token: "dead-owner", + })}\n`, + ); + const oldTimestamp = new Date(Date.now() - 10_000); + fs.utimesSync(lockPath, oldTimestamp, oldTimestamp); + + let entered = false; + await withWorktreePortRegistryLock(homeDir, async () => { + entered = true; + }); + + expect(entered).toBe(true); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it("reclaims an old lock when its pid belongs to a different process", async () => { + const homeDir = makeTemporaryRoot(); + const lockPath = path.join(homeDir, ".worktree-port-reservations.lock"); + fs.mkdirSync(lockPath); + fs.writeFileSync( + path.join(lockPath, "owner.json"), + `${JSON.stringify({ + version: 1, + pid: process.pid, + processIdentity: "reused-pid-owner", + probePort: 1, + token: "abandoned-owner", + })}\n`, + ); + const oldTimestamp = new Date(Date.now() - 10_000); + fs.utimesSync(lockPath, oldTimestamp, oldTimestamp); + + let entered = false; + await withWorktreePortRegistryLock(homeDir, async () => { + entered = true; + }); + + expect(entered).toBe(true); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it("refreshes the lease while a synchronous critical section blocks the main thread", () => { + const homeDir = makeTemporaryRoot(); + const lockPath = path.join(homeDir, ".worktree-port-reservations.lock"); + const blocker = new Int32Array(new SharedArrayBuffer(4)); + + withWorktreePortRegistryLockSync(homeDir, () => { + const oldTimestamp = new Date(Date.now() - 10_000); + fs.utimesSync(lockPath, oldTimestamp, oldTimestamp); + Atomics.wait(blocker, 0, 0, 1_500); + expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeLessThan(1_250); + }); + + expect(fs.existsSync(lockPath)).toBe(false); + }); +}); diff --git a/packages/shared/src/worktree-port-registry.ts b/packages/shared/src/worktree-port-registry.ts new file mode 100644 index 00000000000..4c3a0a2b40d --- /dev/null +++ b/packages/shared/src/worktree-port-registry.ts @@ -0,0 +1,411 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { Worker } from "node:worker_threads"; + +type WorktreePortRegistry = { + version: 1; + configPaths: string[]; +}; + +const WORKTREE_PORT_REGISTRY_FILE = "worktree-port-reservations.json"; +const WORKTREE_PORT_REGISTRY_LOCK_DIR = ".worktree-port-reservations.lock"; +const WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE = "owner.json"; +const WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE = "owner.backup.json"; +const WORKTREE_PORT_REGISTRY_LOCK_STALE_MS = 5_000; +const WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS = 10_000; +const WORKTREE_PORT_REGISTRY_LOCK_HEARTBEAT_MS = 1_000; +const WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS = 500; +const sleepSyncBuffer = new Int32Array(new SharedArrayBuffer(4)); + +type RegistryLockOwner = { + version: 1; + pid: number; + processIdentity: string; + probePort: number; + token: string; +}; + +type RegistryLockLease = { + token: string; + worker: Worker; + control: Int32Array; + probePort: number; +}; + +const REGISTRY_LOCK_HEARTBEAT_SOURCE = ` +const fs = require("node:fs"); +const net = require("node:net"); +const path = require("node:path"); +const { parentPort, workerData } = require("node:worker_threads"); +const control = new Int32Array(workerData.control); +function touchOwnedLock() { + let sawDifferentOwner = false; + for (const ownerFile of workerData.ownerFiles) { + try { + const owner = JSON.parse(fs.readFileSync(path.join(workerData.lockPath, ownerFile), "utf8")); + if (owner.token === workerData.token) { + try { + const now = new Date(); + fs.utimesSync(workerData.lockPath, now, now); + return "refreshed"; + } catch { + return "retry"; + } + } + sawDifferentOwner = true; + } catch { + // The redundant owner file may still be readable. + } + } + return sawDifferentOwner ? "lost" : "retry"; +} +let finished = false; +let timer; +function complete() { + Atomics.store(control, 0, 2); + Atomics.notify(control, 0); +} +function finish() { + if (finished) return; + finished = true; + if (timer) clearInterval(timer); + server.close(complete); + setTimeout(complete, 250).unref(); +} +const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + socket.setTimeout(workerData.probeTimeoutMs, () => socket.destroy()); + socket.once("data", (candidate) => { + socket.end(candidate === workerData.token ? "owned" : "denied"); + }); +}); +parentPort.once("message", finish); +server.once("error", () => { + Atomics.store(control, 0, -1); + Atomics.notify(control, 0); +}); +server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + Atomics.store(control, 0, -1); + Atomics.notify(control, 0); + return; + } + Atomics.store(control, 2, address.port); + Atomics.store(control, 0, 1); + Atomics.notify(control, 0); + touchOwnedLock(); + timer = setInterval(() => { + if (Atomics.load(control, 1) !== 0 || touchOwnedLock() === "lost") finish(); + }, workerData.heartbeatMs); +}); +`; + +const REGISTRY_LOCK_PROBE_SOURCE = ` +const net = require("node:net"); +const { workerData } = require("node:worker_threads"); +const control = new Int32Array(workerData.control); +let finished = false; +function finish(result) { + if (finished) return; + finished = true; + Atomics.store(control, 0, result); + Atomics.notify(control, 0); + process.exit(0); +} +const socket = net.createConnection({ host: "127.0.0.1", port: workerData.port }); +socket.setEncoding("utf8"); +socket.setTimeout(workerData.timeoutMs, () => finish(2)); +socket.once("connect", () => socket.write(workerData.token)); +socket.once("data", (response) => finish(response === "owned" ? 1 : 2)); +socket.once("error", () => finish(2)); +socket.once("close", () => finish(2)); +`; + +function resolveRegistryLockPath(homeDir: string): string { + fs.mkdirSync(homeDir, { recursive: true }); + return path.resolve(homeDir, WORKTREE_PORT_REGISTRY_LOCK_DIR); +} + +function readRegistryLockOwner(lockPath: string): RegistryLockOwner | null { + for (const ownerFile of [ + WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE, + WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE, + ]) { + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(lockPath, ownerFile), "utf8"), + ) as Partial; + if ( + parsed.version === 1 + && Number.isInteger(parsed.pid) + && (parsed.pid ?? 0) > 0 + && typeof parsed.processIdentity === "string" + && parsed.processIdentity.length > 0 + && Number.isInteger(parsed.probePort) + && (parsed.probePort ?? 0) > 0 + && (parsed.probePort ?? 0) <= 65_535 + && typeof parsed.token === "string" + && parsed.token.length > 0 + ) { + return parsed as RegistryLockOwner; + } + } catch { + // Try the redundant owner record. + } + } + return null; +} + +function writeRegistryLockOwner(lockPath: string, owner: RegistryLockOwner): void { + const contents = `${JSON.stringify(owner)}\n`; + for (const ownerFile of [ + WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE, + WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE, + ]) { + const ownerPath = path.join(lockPath, ownerFile); + const temporaryPath = `${ownerPath}.${owner.token}.tmp`; + fs.writeFileSync(temporaryPath, contents, { mode: 0o600 }); + fs.renameSync(temporaryPath, ownerPath); + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !(error instanceof Error && "code" in error && error.code === "ESRCH"); + } +} + +function readProcessIdentity(pid: number): string | null { + if (!Number.isInteger(pid) || pid <= 0) return null; + if (process.platform === "linux") { + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + const commandEnd = stat.lastIndexOf(")"); + if (commandEnd < 0) return null; + const fields = stat.slice(commandEnd + 1).trim().split(/\s+/); + const startTicks = fields[19]; + if (!startTicks) return null; + const bootId = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); + return bootId ? `linux:${bootId}:${startTicks}` : null; + } catch { + return null; + } + } + + try { + if (process.platform === "win32") { + const ticks = execFileSync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`, + ], { encoding: "utf8", windowsHide: true }).trim(); + return ticks ? `win32:${ticks}` : null; + } + const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { + encoding: "utf8", + }).trim(); + return startedAt ? `${process.platform}:${startedAt}` : null; + } catch { + return null; + } +} + +function probeRegistryLockOwner(owner: RegistryLockOwner): boolean { + const control = new Int32Array(new SharedArrayBuffer(4)); + const worker = new Worker(REGISTRY_LOCK_PROBE_SOURCE, { + eval: true, + execArgv: [], + workerData: { + control: control.buffer, + port: owner.probePort, + timeoutMs: WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS, + token: owner.token, + }, + }); + Atomics.wait(control, 0, 0, WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS + 250); + const isOwner = Atomics.load(control, 0) === 1; + void worker.terminate(); + return isOwner; +} + +function removeStaleRegistryLock(lockPath: string): boolean { + try { + const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + if (ageMs <= WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) return false; + const owner = readRegistryLockOwner(lockPath); + if (owner && isProcessAlive(owner.pid)) { + if (probeRegistryLockOwner(owner)) return false; + const currentIdentity = readProcessIdentity(owner.pid); + if (owner.processIdentity === currentIdentity) return false; + } + const currentOwner = readRegistryLockOwner(lockPath); + if (owner ? currentOwner?.token !== owner.token : currentOwner !== null) return false; + const currentAgeMs = Date.now() - fs.statSync(lockPath).mtimeMs; + if (currentAgeMs <= WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) return false; + fs.rmSync(lockPath, { recursive: true, force: true }); + return true; + } catch { + return false; + } +} + +function startRegistryLockHeartbeat(lockPath: string, token: string): RegistryLockLease { + const control = new Int32Array(new SharedArrayBuffer(12)); + const worker = new Worker(REGISTRY_LOCK_HEARTBEAT_SOURCE, { + eval: true, + execArgv: [], + workerData: { + control: control.buffer, + heartbeatMs: WORKTREE_PORT_REGISTRY_LOCK_HEARTBEAT_MS, + lockPath, + ownerFiles: [ + WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE, + WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE, + ], + probeTimeoutMs: WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS, + token, + }, + }); + Atomics.wait(control, 0, 0, 2_000); + if (Atomics.load(control, 0) !== 1) { + void worker.terminate(); + throw new Error(`Failed to start worktree port reservation lock heartbeat at ${lockPath}`); + } + const probePort = Atomics.load(control, 2); + if (probePort <= 0) { + void worker.terminate(); + throw new Error(`Failed to start worktree port reservation lock probe at ${lockPath}`); + } + return { token, worker, control, probePort }; +} + +function stopRegistryLockHeartbeat(lease: RegistryLockLease): void { + Atomics.store(lease.control, 1, 1); + Atomics.notify(lease.control, 1); + lease.worker.postMessage("stop"); + if (Atomics.load(lease.control, 0) === 1) { + Atomics.wait(lease.control, 0, 1, 2_000); + } + void lease.worker.terminate(); +} + +function acquireRegistryLock(lockPath: string, deadline: number): RegistryLockLease | null { + try { + fs.mkdirSync(lockPath); + const token = `${process.pid}-${randomUUID()}`; + let lease: RegistryLockLease | null = null; + try { + const processIdentity = readProcessIdentity(process.pid); + if (!processIdentity) { + throw new Error("Cannot determine worktree port reservation lock owner identity"); + } + const owner: RegistryLockOwner = { + version: 1, + pid: process.pid, + processIdentity, + probePort: 1, + token, + }; + writeRegistryLockOwner(lockPath, owner); + lease = startRegistryLockHeartbeat(lockPath, token); + writeRegistryLockOwner(lockPath, { ...owner, probePort: lease.probePort }); + return lease; + } catch (error) { + if (lease) stopRegistryLockHeartbeat(lease); + fs.rmSync(lockPath, { recursive: true, force: true }); + throw error; + } + } catch (error) { + const code = error instanceof Error && "code" in error ? error.code : null; + if (code !== "EEXIST") throw error; + if (removeStaleRegistryLock(lockPath)) return null; + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for worktree port reservation lock at ${lockPath}`); + } + return null; + } +} + +function releaseRegistryLock(lockPath: string, lease: RegistryLockLease): void { + stopRegistryLockHeartbeat(lease); + const owner = readRegistryLockOwner(lockPath); + if (owner?.token !== lease.token) { + return; + } + fs.rmSync(lockPath, { recursive: true, force: true }); +} + +export function withWorktreePortRegistryLockSync(homeDir: string, run: () => T): T { + const lockPath = resolveRegistryLockPath(homeDir); + const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS; + let lease: RegistryLockLease | null = null; + + while (!(lease = acquireRegistryLock(lockPath, deadline))) { + Atomics.wait(sleepSyncBuffer, 0, 0, 25); + } + + try { + return run(); + } finally { + releaseRegistryLock(lockPath, lease); + } +} + +export async function withWorktreePortRegistryLock( + homeDir: string, + run: () => Promise, +): Promise { + const lockPath = resolveRegistryLockPath(homeDir); + const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS; + let lease: RegistryLockLease | null = null; + + while (!(lease = acquireRegistryLock(lockPath, deadline))) { + await delay(25); + } + + try { + return await run(); + } finally { + releaseRegistryLock(lockPath, lease); + } +} + +export function readWorktreePortRegistry(homeDir: string): Set { + const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE); + if (!fs.existsSync(registryPath)) return new Set(); + + try { + const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")) as Partial; + if (parsed.version !== 1 || !Array.isArray(parsed.configPaths)) return new Set(); + return new Set( + parsed.configPaths + .filter((configPath): configPath is string => typeof configPath === "string" && configPath.length > 0) + .map((configPath) => path.resolve(configPath)), + ); + } catch { + return new Set(); + } +} + +export function writeWorktreePortRegistry(homeDir: string, configPaths: Iterable): void { + fs.mkdirSync(homeDir, { recursive: true }); + const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE); + const persistedPaths = Array.from(new Set(Array.from(configPaths, (configPath) => path.resolve(configPath)))) + .filter((configPath) => fs.existsSync(configPath)) + .sort(); + const registry: WorktreePortRegistry = { + version: 1, + configPaths: persistedPaths, + }; + const temporaryPath = `${registryPath}.${process.pid}.tmp`; + fs.writeFileSync(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(temporaryPath, registryPath); +} diff --git a/packages/shared/src/worktree-seed-source.ts b/packages/shared/src/worktree-seed-source.ts index dfb78a59e69..fca8556befa 100644 --- a/packages/shared/src/worktree-seed-source.ts +++ b/packages/shared/src/worktree-seed-source.ts @@ -14,6 +14,13 @@ export type CanonicalWorktreeSeedSource = { targetInstanceId: string; }; +export type RegisteredWorktreeSeedSourceInput = { + registeredBaseWorkspaceCwd?: string | null; + explicitSourceConfigPath?: string | null; + targetConfigPath: string; + expectedTargetInstanceId: string; +}; + function readInstanceId(configPath: string, label: "source" | "target"): string { const envPath = path.join(path.dirname(configPath), ".env"); if (!existsSync(envPath)) { @@ -47,23 +54,10 @@ function canonicalRegularFile(filePath: string, label: string): string { return canonical; } -/** - * Resolve a worktree seed source without granting authority to the seed manifest. - * - * A managed caller supplies the project-workspace cwd from its server-owned row. - * An operator may instead supply an explicit source config. When both are present, - * the explicit path must still equal the registered project-workspace config. - * Manifest source fields are diagnostic assertions only and can only make this - * validation fail; they never select the returned source. - */ -export function resolveCanonicalWorktreeSeedSource(input: { - registeredBaseWorkspaceCwd?: string | null; - explicitSourceConfigPath?: string | null; - targetConfigPath: string; - expectedTargetInstanceId: string; - manifestSource: WorktreeSeedSourceDiagnostic | null | undefined; - manifestTargetInstanceId?: unknown; -}): CanonicalWorktreeSeedSource { +/** Resolve the authoritative source and target identities without consulting diagnostics. */ +export function resolveRegisteredWorktreeSeedSource( + input: RegisteredWorktreeSeedSourceInput, +): CanonicalWorktreeSeedSource { const registeredCwd = input.registeredBaseWorkspaceCwd?.trim(); const explicitSource = input.explicitSourceConfigPath?.trim(); if (!registeredCwd && !explicitSource) { @@ -120,6 +114,29 @@ export function resolveCanonicalWorktreeSeedSource(input: { throw new Error("Source and target Paperclip configs name the same instance."); } + return { + baseWorkspaceCwd: canonicalBaseCwd, + configPath: canonicalSourceConfigPath, + instanceId: sourceInstanceId, + targetConfigPath: canonicalTargetConfigPath, + targetInstanceId, + }; +} + +/** + * Resolve a worktree seed source without granting authority to the seed manifest. + * + * A managed caller supplies the project-workspace cwd from its server-owned row. + * An operator may instead supply an explicit source config. When both are present, + * the explicit path must still equal the registered project-workspace config. + * Manifest source fields are diagnostic assertions only and never select the + * returned source. + */ +export function resolveCanonicalWorktreeSeedSource(input: RegisteredWorktreeSeedSourceInput & { + manifestSource: WorktreeSeedSourceDiagnostic | null | undefined; + manifestTargetInstanceId?: unknown; +}): CanonicalWorktreeSeedSource { + const registered = resolveRegisteredWorktreeSeedSource(input); const diagnosticPath = typeof input.manifestSource?.configPath === "string" ? input.manifestSource.configPath.trim() : ""; @@ -130,21 +147,14 @@ export function resolveCanonicalWorktreeSeedSource(input: { diagnosticPath, "Worktree seed manifest source diagnostic", ); - if (path.resolve(diagnosticPath) !== canonicalSourceConfigPath || canonicalDiagnosticPath !== canonicalSourceConfigPath) { + if (path.resolve(diagnosticPath) !== registered.configPath || canonicalDiagnosticPath !== registered.configPath) { throw new Error("Worktree seed manifest source path does not match the registered canonical source."); } - if (input.manifestSource?.instanceId !== sourceInstanceId) { + if (input.manifestSource?.instanceId !== registered.instanceId) { throw new Error("Worktree seed manifest source instance does not match the registered source instance."); } - if (input.manifestTargetInstanceId !== targetInstanceId) { + if (input.manifestTargetInstanceId !== registered.targetInstanceId) { throw new Error("Worktree seed manifest target instance does not match the registered target instance."); } - - return { - baseWorkspaceCwd: canonicalBaseCwd, - configPath: canonicalSourceConfigPath, - instanceId: sourceInstanceId, - targetConfigPath: canonicalTargetConfigPath, - targetInstanceId, - }; + return registered; } diff --git a/scripts/__tests__/provision-worktree-self-heal.test.mjs b/scripts/__tests__/provision-worktree-self-heal.test.mjs index 7a3179ea664..91aa420358d 100644 --- a/scripts/__tests__/provision-worktree-self-heal.test.mjs +++ b/scripts/__tests__/provision-worktree-self-heal.test.mjs @@ -71,7 +71,21 @@ if (cliArgs[0] === "worktree" && cliArgs[1] === "ensure-seeded") { process.exit(${ensureExit}); } fs.rmSync(".paperclip/seed-pending", { force: true }); - fs.writeFileSync(".paperclip/seed-complete", "{}\\n"); + fs.rmSync(".paperclip/seed-complete", { force: true }); + fs.writeFileSync(".paperclip/seed-manifest.json", JSON.stringify({ + version: 2, + source: { instanceId: "base-source", configPath: ${JSON.stringify(path.join(baseCwd, ".paperclip", "config.json"))} }, + snapshotAt: "2026-08-19T00:00:00.000Z", + seedMode: "minimal", + migrationRevision: "0142_test.sql", + targetInstanceId: "target-test", + phase: "complete", + state: "verified", + attemptId: "attempt-test", + startedAt: "2026-08-19T00:00:00.000Z", + finishedAt: "2026-08-19T00:01:00.000Z", + diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-19T00:01:00.000Z" }], + }) + "\\n"); process.exit(0); } process.exit(0); @@ -182,6 +196,45 @@ test("falls back to an isolated config when the base CLI cannot boot", () => { ); }); +test("reconciles deployment mode from the registered source when reusing a guest config", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 1, initExit: 0 }); + const { result: first, worktreeCwd, worktreesHome } = runProvision(baseCwd); + assert.equal(first.status, 0, first.stderr); + assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "local_trusted"); + + fs.writeFileSync( + path.join(baseCwd, ".paperclip", "config.json"), + `${JSON.stringify({ + server: { + deploymentMode: "authenticated", + exposure: "private", + }, + }, null, 2)}\n`, + ); + + const second = spawnSync("bash", [script], { + cwd: worktreeCwd, + encoding: "utf8", + env: { + PATH: testPath, + HOME: os.homedir(), + PAPERCLIP_WORKSPACE_BASE_CWD: baseCwd, + PAPERCLIP_WORKSPACE_CWD: worktreeCwd, + PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-test", + PAPERCLIP_WORKTREES_DIR: worktreesHome, + PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"), + PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1", + PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1", + }, + }); + + assert.equal(second.status, 0, second.stderr); + assert.match(second.stderr, /Reusing existing isolated Paperclip worktree config/); + assert.match(second.stderr, /Reconciled isolated Paperclip worktree deployment mode/); + assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "authenticated"); + assert.equal(readWorktreeConfig(worktreeCwd).server.exposure, "private"); +}); + test("repairs an unhealthy base install under the lock and then uses the CLI", (t) => { const hasTools = ["flock", "git"].every( (tool) => spawnSync("bash", ["-lc", `command -v ${tool}`], { env: { PATH: testPath } }).status === 0, @@ -276,7 +329,10 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe const first = runRuntimeProvision(baseCwd, worktreeCwd); assert.equal(first.status, 0, first.stderr); - assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete"))); + assert.equal( + JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, + "verified", + ); assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); const ensureCallsAfterFirst = readCliInvocations(baseCwd) @@ -287,12 +343,58 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe const second = runRuntimeProvision(baseCwd, worktreeCwd); assert.equal(second.status, 0, second.stderr); - assert.match(second.stderr, /already seeded.*skipping/); + assert.match(second.stderr, /verified seed manifest.*skipping/); const ensureCallsAfterSecond = readCliInvocations(baseCwd) .filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded"); assert.equal(ensureCallsAfterSecond.length, 1); }); +test("runtime provisioning seeds a worktree config that has no seed markers", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + const worktreeCwd = makeTempDir("paperclip-provision-runtime-unmarked-config-"); + fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n"); + + const result = runRuntimeProvision(baseCwd, worktreeCwd); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + readCliInvocations(baseCwd) + .filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length, + 1, + ); + assert.equal( + JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, + "verified", + ); +}); + +test("runtime provisioning bootstraps and seeds an empty .paperclip directory", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 }); + fs.mkdirSync(path.join(baseCwd, "scripts"), { recursive: true }); + fs.copyFileSync(script, path.join(baseCwd, "scripts", "provision-worktree.sh")); + const worktreeCwd = makeTempDir("paperclip-provision-runtime-empty-state-"); + fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true }); + + const result = runRuntimeProvision(baseCwd, worktreeCwd); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /config is missing; running the built-in worktree provisioner/); + const invocations = readCliInvocations(baseCwd); + assert.equal( + invocations.filter((args) => args[0] === "worktree" && args[1] === "init").length, + 1, + ); + assert.equal( + invocations.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length, + 1, + ); + assert.equal( + JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, + "verified", + ); +}); + test("runtime provisioning leaves seed-pending in place when ensure-seeded fails", () => { const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 }); const worktreeCwd = makeTempDir("paperclip-provision-runtime-failure-"); diff --git a/scripts/provision-worktree-runtime.sh b/scripts/provision-worktree-runtime.sh index 99116b21722..b9710344c5c 100755 --- a/scripts/provision-worktree-runtime.sh +++ b/scripts/provision-worktree-runtime.sh @@ -6,8 +6,6 @@ worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" paperclip_dir="$worktree_cwd/.paperclip" worktree_config_path="$paperclip_dir/config.json" seed_manifest_path="$paperclip_dir/seed-manifest.json" -seed_pending_marker_path="$paperclip_dir/seed-pending" -seed_complete_marker_path="$paperclip_dir/seed-complete" if [[ ! -d "$base_cwd" ]]; then echo "Base workspace does not exist: $base_cwd" >&2 @@ -48,13 +46,23 @@ EOF echo "Worktree database has a verified seed manifest; skipping runtime provisioning." >&2 exit 0 fi -elif [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then - echo "Worktree database is already seeded by a legacy marker; skipping runtime provisioning." >&2 - exit 0 fi if [[ ! -f "$worktree_config_path" ]]; then - echo "Worktree config does not exist: $worktree_config_path" >&2 + initial_provision_script="$base_cwd/scripts/provision-worktree.sh" + if [[ ! -f "$initial_provision_script" ]]; then + echo "Worktree config does not exist and the built-in provision script is unavailable: $worktree_config_path" >&2 + exit 1 + fi + echo "Worktree config is missing; running the built-in worktree provisioner before database seeding." >&2 + ( + cd "$worktree_cwd" && + bash "$initial_provision_script" + ) +fi + +if [[ ! -f "$worktree_config_path" ]]; then + echo "Worktree config still does not exist after built-in provisioning: $worktree_config_path" >&2 exit 1 fi diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 072cba88389..63fa5703555 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -242,6 +242,51 @@ for (const rawValue of runtimePaths) { EOF } +reconcile_worktree_deployment_mode() { + SOURCE_CONFIG_PATH="$source_config_path" \ + WORKTREE_CONFIG_PATH="$worktree_config_path" \ + node <<'EOF' +const fs = require("node:fs"); +const path = require("node:path"); + +const sourceConfigPath = path.resolve(process.env.SOURCE_CONFIG_PATH); +const worktreeConfigPath = path.resolve(process.env.WORKTREE_CONFIG_PATH); +const sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8")); +const worktreeConfig = JSON.parse(fs.readFileSync(worktreeConfigPath, "utf8")); +const deploymentMode = sourceConfig?.server?.deploymentMode ?? "local_trusted"; +if (deploymentMode !== "local_trusted" && deploymentMode !== "authenticated") { + throw new Error(`Registered source has unsupported server.deploymentMode: ${deploymentMode}`); +} +const exposure = deploymentMode === "local_trusted" + ? "private" + : (sourceConfig?.server?.exposure ?? "private"); +const currentServer = worktreeConfig?.server && typeof worktreeConfig.server === "object" + ? worktreeConfig.server + : {}; +if (currentServer.deploymentMode === deploymentMode && currentServer.exposure === exposure) { + process.exit(0); +} + +worktreeConfig.server = { + ...currentServer, + deploymentMode, + exposure, +}; +if (worktreeConfig.$meta && typeof worktreeConfig.$meta === "object") { + worktreeConfig.$meta.updatedAt = new Date().toISOString(); +} + +const temporaryPath = `${worktreeConfigPath}.deployment-mode-${process.pid}`; +try { + fs.writeFileSync(temporaryPath, `${JSON.stringify(worktreeConfig, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(temporaryPath, worktreeConfigPath); +} finally { + fs.rmSync(temporaryPath, { force: true }); +} +console.error(`Reconciled isolated Paperclip worktree deployment mode from ${sourceConfigPath}: ${deploymentMode}/${exposure}`); +EOF +} + write_seed_pending_manifest() { SEED_MANIFEST_PATH="$seed_manifest_path" \ SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \ @@ -579,6 +624,12 @@ else created_worktree_config=1 fi +# The target config can predate a deployment-mode change on the registered +# source, and older/fallback CLI writers may default this field independently. +# Reconcile it after either create or reuse so the final guest config always +# carries the source's deployment/auth contract without replacing its database. +reconcile_worktree_deployment_mode + if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then write_seed_pending_manifest fi diff --git a/server/src/__tests__/local-service-supervisor.test.ts b/server/src/__tests__/local-service-supervisor.test.ts new file mode 100644 index 00000000000..45748eac2f4 --- /dev/null +++ b/server/src/__tests__/local-service-supervisor.test.ts @@ -0,0 +1,155 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resetRuntimeServicesForTests, + startRuntimeServicesForWorkspaceControl, +} from "../services/workspace-runtime.js"; +import { + doesLocalServiceCommandLineMatch, + listLocalServiceRegistryRecords, + readLocalServicePortOwner, + resolveLocalServiceLogPath, + terminateLocalService, +} from "../services/local-service-supervisor.js"; + +describe("local service supervision", () => { + afterEach(async () => { + await resetRuntimeServicesForTests({ terminateProcesses: true }); + }); + + it("keeps request-logging runtime stdio usable after the supervisor side closes", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-stdio-")); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-home-")); + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = `service-stdio-${randomUUID()}`; + + let registryRecord: Awaited>[number] | null = null; + try { + const [service] = await startRuntimeServicesForWorkspaceControl({ + actor: { id: null, name: "Board", companyId: randomUUID() }, + issue: null, + workspace: { + baseCwd: workspaceRoot, + source: "agent_home", + projectId: null, + workspaceId: null, + repoUrl: null, + repoRef: null, + strategy: "project_primary", + cwd: workspaceRoot, + branchName: null, + worktreePath: null, + warnings: [], + created: false, + }, + config: { + workspaceRuntime: { + services: [{ + name: "web", + command: "node -e \"const http=require('node:http'); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "agent", + stopPolicy: { type: "manual" }, + }], + }, + }, + adapterEnv: {}, + }); + + expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(service?.port).toBeTypeOf("number"); + registryRecord = (await listLocalServiceRegistryRecords({ profileKind: "workspace-runtime" })) + .find((record) => record.runtimeServiceId === service!.id) ?? null; + expect(registryRecord).not.toBeNull(); + + await resetRuntimeServicesForTests({ simulateSupervisorExit: true }); + + await expect(fetch(`${service!.url}/after-restart`)).resolves.toMatchObject({ ok: true }); + const log = await fs.readFile(resolveLocalServiceLogPath(registryRecord!.serviceKey), "utf8"); + expect(log).toContain("request /after-restart"); + + await terminateLocalService(registryRecord!); + expect(await readLocalServicePortOwner(service!.port!)).toBeNull(); + await expect(fetch(service!.url!)).rejects.toThrow(); + registryRecord = null; + } finally { + if (registryRecord) await terminateLocalService(registryRecord).catch(() => undefined); + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId; + await fs.rm(paperclipHome, { recursive: true, force: true }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }, 15_000); + + it("does not wait for an unrelated process that takes over the service port", async () => { + if (process.platform === "win32") return; + const unrelatedListener = net.createServer(); + await new Promise((resolve) => unrelatedListener.listen(0, "127.0.0.1", resolve)); + const address = unrelatedListener.address(); + const port = typeof address === "object" && address ? address.port : null; + if (!port) throw new Error("Failed to allocate unrelated listener port"); + const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { + detached: true, + stdio: "ignore", + }); + child.unref(); + + try { + await terminateLocalService({ + pid: child.pid!, + processGroupId: child.pid!, + port, + }); + expect(await readLocalServicePortOwner(port)).toBe(process.pid); + } finally { + try { + process.kill(-child.pid!, "SIGKILL"); + } catch { + // The target process group was already terminated. + } + await new Promise((resolve, reject) => { + unrelatedListener.close((error) => error ? reject(error) : resolve()); + }); + } + }); + + it("recognizes a pnpm command after the launcher becomes pnpm.cjs", () => { + expect(doesLocalServiceCommandLineMatch({ + commandLine: "/usr/bin/node /opt/pnpm/pnpm.cjs dev -- --bind custom --bind-host 127.0.0.1", + recordedCommand: "pnpm dev -- --bind custom --bind-host 127.0.0.1", + serviceName: "paperclip-dev", + })).toBe(true); + }); + + it("does not accept a different command merely because it uses node", () => { + expect(doesLocalServiceCommandLineMatch({ + commandLine: "/usr/bin/node /workspace/server/dist/index.js", + recordedCommand: "pnpm dev -- --bind custom --bind-host 127.0.0.1", + serviceName: "paperclip-dev", + })).toBe(false); + }); + + it("does not collapse different script paths to the same basename", () => { + expect(doesLocalServiceCommandLineMatch({ + commandLine: "/usr/bin/node /other-workspace/server.js", + recordedCommand: "node ./server.js", + serviceName: "web", + })).toBe(false); + }); +}); diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index b469d1cc775..0c366a84d98 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -448,8 +448,6 @@ describe("resolveRuntimeProvisionCommand", () => { 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, @@ -457,6 +455,17 @@ describe("resolveRuntimeProvisionCommand", () => { worktreePath: cwd, }; + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( + "bash ./scripts/provision-worktree-runtime.sh", + ); + + await fs.mkdir(path.join(cwd, ".paperclip"), { recursive: true }); + await fs.writeFile(path.join(cwd, ".paperclip", "config.json"), "{}\n"); + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( + "bash ./scripts/provision-worktree-runtime.sh", + ); + + await fs.writeFile(path.join(cwd, ".paperclip", "seed-pending"), "{}\n"); expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( "bash ./scripts/provision-worktree-runtime.sh", ); @@ -466,7 +475,9 @@ describe("resolveRuntimeProvisionCommand", () => { })).toBe("./custom-provision.sh"); await fs.writeFile(path.join(cwd, ".paperclip", "seed-complete"), "{}\n"); - expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(""); + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( + "bash ./scripts/provision-worktree-runtime.sh", + ); await fs.writeFile( path.join(cwd, ".paperclip", "seed-manifest.json"), @@ -772,6 +783,55 @@ describe("realizeExecutionWorkspace", () => { expect(second.branchName).toBe(first.branchName); }); + it("defaults the repo-provided worktree provisioner for git worktree strategies", async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "scripts", "provision-worktree.sh"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "mkdir -p .paperclip", + "printf 'provisioned\\n' > .paperclip/default-provision-ran", + "", + ].join("\n"), + "utf8", + ); + await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add repository worktree provisioner"]); + + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-default-provision", + identifier: "PAP-17684", + title: "Default worktree provisioning", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + await expect( + fs.readFile(path.join(workspace.cwd, ".paperclip", "default-provision-ran"), "utf8"), + ).resolves.toBe("provisioned\n"); + }); + it("warns when reusing a git worktree whose base ref has advanced", async () => { const repoRoot = await createTempRepo(); @@ -7230,7 +7290,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { } }, 40_000); - it("adopts a live auto-port shared service after runtime state is reset", async () => { + it("re-adopts a request-logging service on the same auto port after supervisor stdio closes", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-reconcile-")); const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-")); process.env.PAPERCLIP_HOME = paperclipHome; @@ -7239,7 +7299,6 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { const companyId = randomUUID(); const agentId = randomUUID(); const runId = randomUUID(); - const executionWorkspaceId = randomUUID(); await db.insert(companies).values({ id: companyId, @@ -7273,6 +7332,16 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { projectId: null, workspaceId: null, }; + await fs.writeFile( + path.join(workspaceRoot, "request-logger.cjs"), + [ + "const http = require('node:http');", + "http.createServer((req, res) => {", + " process.stdout.write(`request ${req.url}\\n`, (error) => { if (!error) res.end('ok'); });", + "}).listen(Number(process.env.PORT), '127.0.0.1');", + ].join("\n"), + "utf8", + ); leasedRunIds.add(runId); const services = await ensureRuntimeServicesForRun({ @@ -7290,8 +7359,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { services: [ { name: "web", - command: - "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"", + command: "node request-logger.cjs", port: { type: "auto" }, readiness: { type: "http", @@ -7315,9 +7383,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { const service = services[0]; expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true }); + const originalPort = service!.port; + const originalProviderRef = service!.providerRef; - await fs.rm(paperclipHome, { recursive: true, force: true }); - await resetRuntimeServicesForTests(); + // Closing the parent's pipe endpoints reproduces a real control-plane exit. + // A service whose per-request logger still targets those pipes wedges (or + // exits) on the reconciliation health probe instead of answering it. + await resetRuntimeServicesForTests({ simulateSupervisorExit: true }); const result = await reconcilePersistedRuntimeServicesOnStartup(db); expect(result).toMatchObject({ reconciled: 1, adopted: 1, stopped: 0 }); @@ -7328,17 +7400,221 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { .where(eq(workspaceRuntimeServices.id, service!.id)) .then((rows) => rows[0] ?? null); expect(persisted?.status).toBe("running"); - expect(persisted?.providerRef).toMatch(/^\d+$/); + expect(persisted?.port).toBe(originalPort); + expect(persisted?.providerRef).toBe(originalProviderRef); + await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true }); - await stopRuntimeServicesForExecutionWorkspace({ - db, - executionWorkspaceId, - workspaceCwd: workspace.cwd, - }); + await resetRuntimeServicesForTests({ terminateProcesses: true }); + leasedRunIds.delete(runId); + await fs.rm(paperclipHome, { recursive: true, force: true }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); await expect(fetch(service!.url!)).rejects.toThrow(); }); + it("re-adopts a desired service when pnpm is represented as the pnpm.cjs launcher", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-pnpm-reconcile-")); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-")); + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = `runtime-pnpm-reconcile-${randomUUID()}`; + + const portProbe = net.createServer(); + await new Promise((resolve) => portProbe.listen(0, "127.0.0.1", resolve)); + const address = portProbe.address(); + const port = typeof address === "object" && address ? address.port : null; + await new Promise((resolve, reject) => { + portProbe.close((error) => error ? reject(error) : resolve()); + }); + if (!port) throw new Error("Failed to reserve pnpm reconciliation test port"); + + const companyId = randomUUID(); + const projectId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const runtimeServiceId = randomUUID(); + const command = "pnpm dev"; + const service = { + name: "web", + command, + port, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 50, + }, + expose: null, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { type: "manual" }, + }; + const reuseKey = createHash("sha256") + .update(stableStringifyForTest({ + scopeType: "execution_workspace", + scopeId: executionWorkspaceId, + serviceName: service.name, + command, + cwd: workspaceRoot, + port, + env: {}, + expose: null, + })) + .digest("hex"); + const wrapperPath = path.join(workspaceRoot, "pnpm.cjs"); + await fs.writeFile( + wrapperPath, + [ + "const http = require('node:http');", + "const port = Number(process.argv[3]);", + "http.createServer((_req, res) => res.end('ok')).listen(port, '127.0.0.1');", + ].join("\n"), + "utf8", + ); + const child = spawn(process.execPath, [wrapperPath, "dev", String(port)], { + cwd: workspaceRoot, + detached: process.platform !== "win32", + stdio: "ignore", + }); + child.unref(); + + try { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + if ((await fetch(`http://127.0.0.1:${port}`)).ok) break; + } catch { + // Wait for the simulated package-manager launcher to bind. + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + await expect(fetch(`http://127.0.0.1:${port}`)).resolves.toMatchObject({ ok: true }); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime pnpm reconciliation", + status: "in_progress", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: null, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Runtime pnpm reconciliation workspace", + status: "active", + cwd: workspaceRoot, + providerType: "local_fs", + providerRef: workspaceRoot, + metadata: { + config: { + workspaceRuntime: { services: [service] }, + desiredState: "running", + serviceStates: { "0": "running" }, + }, + }, + }); + await db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + projectWorkspaceId: null, + executionWorkspaceId, + issueId: null, + scopeType: "execution_workspace", + scopeId: executionWorkspaceId, + serviceName: service.name, + status: "running", + lifecycle: "shared", + reuseKey, + command, + cwd: workspaceRoot, + port, + url: `http://127.0.0.1:${port}`, + provider: "local_process", + providerRef: String(child.pid), + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: new Date(), + startedAt: new Date(), + stoppedAt: null, + stopPolicy: { type: "manual" }, + healthStatus: "healthy", + }); + await writeLocalServiceRegistryRecord({ + version: 1, + serviceKey: `workspace-runtime-web-${randomUUID()}`, + profileKind: "workspace-runtime", + serviceName: service.name, + command, + cwd: workspaceRoot, + envFingerprint: reuseKey, + port, + url: `http://127.0.0.1:${port}`, + pid: child.pid!, + processGroupId: child.pid ?? null, + provider: "local_process", + runtimeServiceId, + reuseKey, + startedAt: new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { executionWorkspaceId }, + }); + + const result = await reconcilePersistedRuntimeServicesOnStartup(db); + + expect(result).toMatchObject({ + reconciled: 1, + adopted: 1, + stopped: 0, + restarted: 0, + restartFailed: 0, + }); + const rows = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceId)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + id: runtimeServiceId, + status: "running", + healthStatus: "healthy", + port, + providerRef: String(child.pid), + }); + expect(await readLocalServicePortOwner(port)).toBe(child.pid); + await expect(fetch(`http://127.0.0.1:${port}`)).resolves.toMatchObject({ ok: true }); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: workspaceRoot, + }).catch(() => undefined); + if (child.pid) { + try { + if (process.platform === "win32") process.kill(child.pid, "SIGKILL"); + else process.kill(-child.pid, "SIGKILL"); + } catch { + // The adopted runtime was already stopped through the managed path. + } + } + await resetRuntimeServicesForTests(); + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId; + await fs.rm(paperclipHome, { recursive: true, force: true }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }, 20_000); + it("does not reuse a stopped auto-port service port while another process owns it", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-unhealthy-adopt-")); const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-")); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 36a859d12b2..56e3063af35 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -444,13 +444,27 @@ describe("worktree config repair", () => { const firstConfigPath = path.join(firstWorktreeRoot, ".paperclip", "config.json"); const secondConfigPath = path.join(secondWorktreeRoot, ".paperclip", "config.json"); - const writeWorktree = async (worktreeRoot: string, name: string) => { + const writeWorktree = async ( + worktreeRoot: string, + name: string, + databaseMode: "embedded-postgres" | "postgres" = "embedded-postgres", + ) => { const paperclipDir = path.join(worktreeRoot, ".paperclip"); const instanceRoot = path.join(isolatedHome, "instances", name.toLowerCase()); + const config = buildIsolatedConfig(instanceRoot, 45439, 55439); await fs.mkdir(paperclipDir, { recursive: true }); await fs.writeFile( path.join(paperclipDir, "config.json"), - `${JSON.stringify(buildIsolatedConfig(instanceRoot, 45439, 55439), null, 2)}\n`, + `${JSON.stringify({ + ...config, + database: { + ...config.database, + mode: databaseMode, + ...(databaseMode === "postgres" + ? { connectionString: "postgres://paperclip:paperclip@127.0.0.1:55439/paperclip" } + : {}), + }, + }, null, 2)}\n`, "utf8", ); await fs.writeFile( @@ -480,7 +494,7 @@ describe("worktree config repair", () => { delete process.env.DATABASE_URL; }; - await writeWorktree(firstWorktreeRoot, "PAP-14013-import-bulk-skills"); + await writeWorktree(firstWorktreeRoot, "PAP-14013-import-bulk-skills", "postgres"); await writeWorktree(secondWorktreeRoot, "PAP-14069-port-conflicts"); const staleLockPath = path.join(isolatedHome, ".worktree-port-reservations.lock"); await fs.mkdir(staleLockPath, { recursive: true }); diff --git a/server/src/__tests__/worktree-seed-server-spawn.test.ts b/server/src/__tests__/worktree-seed-server-spawn.test.ts new file mode 100644 index 00000000000..7b94af832c6 --- /dev/null +++ b/server/src/__tests__/worktree-seed-server-spawn.test.ts @@ -0,0 +1,229 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { + ensureWorktreeSeeded, + readWorktreeSeedManifest, +} from "../../../cli/src/commands/worktree.ts"; +import { realizeExecutionWorkspace } from "../services/workspace-runtime.ts"; + +const execFileAsync = promisify(execFile); +const cleanup: string[] = []; +const originalConfig = process.env.PAPERCLIP_CONFIG; +const originalWorktreesDir = process.env.PAPERCLIP_WORKTREES_DIR; + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }); +} + +async function writeConfig(configPath: string, instanceId: string) { + const instanceRoot = path.dirname(configPath); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + await fs.writeFile(configPath, `${JSON.stringify({ + $meta: { version: 1, updatedAt: "2026-08-19T00:00:00.000Z", source: "configure" }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(instanceRoot, "db"), + embeddedPostgresPort: 54329, + backup: { enabled: false, intervalMinutes: 60, retentionDays: 30, dir: path.join(instanceRoot, "backups") }, + }, + logging: { mode: "file", logDir: path.join(instanceRoot, "logs") }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + }, null, 2)}\n`, "utf8"); + await fs.writeFile( + path.join(path.dirname(configPath), ".env"), + `PAPERCLIP_INSTANCE_ID=${instanceId}\n`, + "utf8", + ); +} + +function verifiedSeedResult() { + return { + backupSummary: "snapshot.sql", + snapshotAt: "2026-08-19T00:00:00.000Z", + migrationRevision: "0223_test.sql", + pausedScheduledRoutines: 0, + executionQuarantine: { + disabledTimerHeartbeats: 0, + resetRunningAgents: 0, + quarantinedInProgressIssues: 0, + unassignedTodoIssues: 0, + unassignedReviewIssues: 0, + stoppedProjectWorkspaceRuntimes: 0, + stoppedExecutionWorkspaceRuntimes: 0, + stoppedRuntimeServices: 0, + }, + reboundWorkspaces: [], + validation: { + authUserCount: 1, + credentialAccountCount: 1, + instanceAdminCount: 1, + activeMembershipCount: 1, + companyCount: 1, + issueCount: 1, + representativeCompanyId: "00000000-0000-4000-8000-000000000001", + representativeIssueId: "00000000-0000-4000-8000-000000000002", + migrationRevision: "0223_test.sql", + }, + }; +} + +afterEach(async () => { + if (originalConfig === undefined) delete process.env.PAPERCLIP_CONFIG; + else process.env.PAPERCLIP_CONFIG = originalConfig; + if (originalWorktreesDir === undefined) delete process.env.PAPERCLIP_WORKTREES_DIR; + else process.env.PAPERCLIP_WORKTREES_DIR = originalWorktreesDir; + for (const dir of cleanup.splice(0)) { + await fs.rm(dir, { recursive: true, force: true }); + } +}); + +describe("managed worktree seed source through the server spawn path", () => { + it("re-derives an ambient-instance manifest written before provisioning", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-server-seed-source-")); + cleanup.push(tempRoot); + const repoRoot = path.join(tempRoot, "repo"); + const hooksDir = path.join(tempRoot, "hooks"); + const ambientConfigPath = path.join(tempRoot, "ambient", "config.json"); + const registeredConfigPath = path.join(repoRoot, ".paperclip", "config.json"); + const worktreeHome = path.join(tempRoot, "worktree-home"); + + await fs.mkdir(repoRoot, { recursive: true }); + await runGit(repoRoot, ["init", "-q"]); + await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.writeFile(path.join(repoRoot, "README.md"), "server spawn regression\n", "utf8"); + await fs.copyFile( + fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)), + path.join(repoRoot, "scripts", "provision-worktree.sh"), + ); + await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755); + await runGit(repoRoot, ["add", "README.md", "scripts/provision-worktree.sh"]); + await runGit(repoRoot, ["commit", "-qm", "Add managed provision script"]); + await writeConfig(registeredConfigPath, "registered-source"); + await writeConfig(ambientConfigPath, "ambient-instance"); + await fs.mkdir(worktreeHome, { recursive: true }); + + // Reproduce the integrated lane's ordering with a checkout hook: an earlier + // worktree-init-style writer inherits the server environment and leaves a valid + // target config whose manifest diagnostic points at the ambient instance. + await fs.mkdir(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, "post-checkout"); + await fs.writeFile( + hookPath, + `#!/usr/bin/env node +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); +const cwd = process.cwd(); +if (cwd === ${JSON.stringify(repoRoot)}) process.exit(0); +const stateDir = path.join(cwd, ".paperclip"); +const normalized = path.basename(cwd).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^[-_]+|[-_]+$/g, ""); +const instanceId = \`${"${(normalized || \"worktree\").slice(0, 48)}"}-\${crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 12)}\`; +const targetConfigPath = path.join(stateDir, "config.json"); +const instanceRoot = path.join(process.env.PAPERCLIP_WORKTREES_DIR, "instances", instanceId); +fs.mkdirSync(stateDir, { recursive: true }); +fs.mkdirSync(instanceRoot, { recursive: true }); +fs.writeFileSync(targetConfigPath, JSON.stringify({ + $meta: { version: 1, updatedAt: "2026-08-19T00:00:00.000Z", source: "configure" }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(instanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { enabled: false, intervalMinutes: 60, retentionDays: 30, dir: path.join(instanceRoot, "backups") }, + }, + logging: { mode: "file", logDir: path.join(instanceRoot, "logs") }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, +}, null, 2) + "\\n"); +fs.writeFileSync(path.join(stateDir, ".env"), [ + "PAPERCLIP_HOME=" + JSON.stringify(process.env.PAPERCLIP_WORKTREES_DIR), + "PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId), + "PAPERCLIP_CONFIG=" + JSON.stringify(targetConfigPath), + "", +].join("\\n")); +fs.writeFileSync(path.join(stateDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "ambient-instance", configPath: process.env.PAPERCLIP_CONFIG }, + snapshotAt: null, + seedMode: "minimal", + migrationRevision: null, + targetInstanceId: instanceId, + phase: "pending", + state: "pending", + attemptId: "ambient-writer", + startedAt: null, + finishedAt: null, + diagnostics: [{ phase: "pending", status: "succeeded", at: new Date().toISOString() }], +}, null, 2) + "\\n"); +`, + "utf8", + ); + await fs.chmod(hookPath, 0o755); + await runGit(repoRoot, ["config", "core.hooksPath", hooksDir]); + + process.env.PAPERCLIP_CONFIG = ambientConfigPath; + process.env.PAPERCLIP_WORKTREES_DIR = worktreeHome; + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "project-workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-17681", + title: "Recover ambient seed source", + }, + agent: { id: "agent-1", name: "Coder", companyId: "company-1" }, + }); + + expect(readWorktreeSeedManifest(path.join(workspace.cwd, ".paperclip", "config.json"))).toMatchObject({ + source: { instanceId: "ambient-instance", configPath: ambientConfigPath }, + state: "pending", + }); + + await expect(ensureWorktreeSeeded({ + config: path.join(workspace.cwd, ".paperclip", "config.json"), + registeredBaseWorkspaceCwd: repoRoot, + registeredProjectWorkspaceId: "project-workspace-1", + expectedCompanyId: "company-1", + }, { + seedDatabase: async () => verifiedSeedResult(), + })).resolves.toMatchObject({ seeded: true, reason: "seeded" }); + + expect(readWorktreeSeedManifest(path.join(workspace.cwd, ".paperclip", "config.json"))).toMatchObject({ + source: { instanceId: "registered-source", configPath: registeredConfigPath }, + state: "verified", + phase: "complete", + }); + }, 20_000); +}); diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 257eee74d53..7e591a1125c 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; @@ -63,6 +64,32 @@ function getRuntimeServicesDir() { return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services"); } +function getRuntimeServiceLogsDir() { + return path.resolve(resolvePaperclipInstanceRoot(), "runtime-service-logs"); +} + +export function resolveLocalServiceLogPath(serviceKey: string) { + if (!/^[a-z0-9._-]+$/.test(serviceKey)) { + throw new Error("Invalid local service key for log path"); + } + return path.resolve(getRuntimeServiceLogsDir(), `${serviceKey}.log`); +} + +/** + * Open a managed service's durable append-only output file. + * + * The returned descriptor is intended to be passed directly to spawn(). The + * child receives its own duplicate, so the caller can close this handle as soon + * as spawn returns without tying the service's stdio lifetime to Paperclip's. + */ +export async function openLocalServiceLogFile(serviceKey: string) { + await fs.mkdir(getRuntimeServiceLogsDir(), { recursive: true }); + const logPath = resolveLocalServiceLogPath(serviceKey); + const handle = await fs.open(logPath, "a+", 0o600); + const startOffset = (await handle.stat()).size; + return { handle, logPath, startOffset }; +} + function getRuntimeServiceRegistryPath(serviceKey: string) { return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`); } @@ -230,10 +257,91 @@ export function isProcessGroupAlive(processGroupId: number | null | undefined) { if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false; try { process.kill(-processGroupId, 0); - return true; } catch { return false; } + + if (process.platform === "linux") { + const liveMember = readLinuxProcessGroupActivity(processGroupId); + if (liveMember !== null) return liveMember; + } + return true; +} + +function readLinuxProcessGroupActivity(processGroupId: number): boolean | null { + let entries: fsSync.Dirent[]; + try { + entries = fsSync.readdirSync("/proc", { withFileTypes: true }); + } catch { + return null; + } + + let foundMember = false; + for (const entry of entries) { + if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue; + try { + const stat = fsSync.readFileSync(`/proc/${entry.name}/stat`, "utf8"); + const commandEnd = stat.lastIndexOf(")"); + if (commandEnd < 0) continue; + const fields = stat.slice(commandEnd + 1).trim().split(/\s+/); + const state = fields[0]; + const memberProcessGroupId = Number.parseInt(fields[2] ?? "", 10); + if (memberProcessGroupId !== processGroupId) continue; + foundMember = true; + if (state !== "Z" && state !== "X") return true; + } catch { + // The process can exit while /proc is scanned. + } + } + + // kill(-pgid, 0) also succeeds for a group that contains only zombies. Such + // processes cannot run or own a listener and are waiting only for their + // parent to reap them, so termination is complete for service-control use. + return foundMember ? false : null; +} + +function tokenizeCommandLine(value: string) { + return value.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g) ?? []; +} + +function normalizeCommandToken(value: string) { + const unquoted = value.replace(/^["']|["']$/g, ""); + const basename = path.basename(unquoted.replace(/\\/g, "/")); + const launcher = basename.replace(/\.(?:cjs|mjs|js|cmd|exe)$/i, ""); + return /^(?:bun|node|nodejs|npm|npx|pnpm|yarn)$/i.test(launcher) ? launcher : unquoted; +} + +/** + * Compare a configured service command with the argv exposed by the OS. + * + * Package-manager launchers commonly replace `pnpm dev` with + * `node /path/to/pnpm.cjs dev` after the shell starts. A literal substring + * check rejects that surviving process even though the executable and all + * configured arguments are still present. Normalize executable paths and + * script extensions, then require the configured argv to remain contiguous. + */ +export function doesLocalServiceCommandLineMatch(input: { + commandLine: string; + recordedCommand: string; + serviceName: string; +}) { + const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim(); + const normalizedCommandLine = normalize(input.commandLine); + const normalizedRecordedCommand = normalize(input.recordedCommand); + if ( + normalizedCommandLine.includes(normalizedRecordedCommand) + || normalizedCommandLine.includes(input.serviceName) + ) { + return true; + } + + const actualTokens = tokenizeCommandLine(input.commandLine).map(normalizeCommandToken); + const recordedTokens = tokenizeCommandLine(input.recordedCommand).map(normalizeCommandToken); + if (recordedTokens.length === 0 || recordedTokens.length > actualTokens.length) return false; + + return actualTokens.some((_, start) => recordedTokens.every( + (token, offset) => actualTokens[start + offset] === token, + )); } async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { @@ -242,10 +350,11 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); const commandLine = stdout.trim(); if (!commandLine) return false; - const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim(); - const normalizedCommandLine = normalize(commandLine); - const normalizedRecordedCommand = normalize(record.command); - return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName); + return doesLocalServiceCommandLineMatch({ + commandLine, + recordedCommand: record.command, + serviceName: record.serviceName, + }); } catch { return true; } @@ -396,11 +505,34 @@ export async function touchLocalServiceRegistryRecord( } export async function terminateLocalService( - record: Pick, - opts?: { signal?: NodeJS.Signals; forceAfterMs?: number }, + record: Pick & + Partial>, + opts?: { signal?: NodeJS.Signals; forceAfterMs?: number; verifyAfterMs?: number }, ) { const signal = opts?.signal ?? "SIGTERM"; const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0; + + const targetIsGone = async () => { + const targetAlive = targetProcessGroup + ? isProcessGroupAlive(record.processGroupId) + : isPidAlive(record.pid); + if (targetAlive) return false; + if (!record.port) return true; + const portOwnerPid = await readLocalServicePortOwner(record.port); + if (!portOwnerPid) return true; + const ownerProcessId = targetProcessGroup ? record.processGroupId! : record.pid; + return !(await isLocalServiceProcessOwnedBy(portOwnerPid, ownerProcessId)); + }; + + const waitUntilGone = async (timeoutMs: number) => { + const deadline = Date.now() + timeoutMs; + do { + if (await targetIsGone()) return true; + await delay(100); + } while (Date.now() < deadline); + return await targetIsGone(); + }; + try { if (targetProcessGroup) { process.kill(-record.processGroupId!, signal); @@ -408,24 +540,10 @@ export async function terminateLocalService( process.kill(record.pid, signal); } } catch { - return; + if (await targetIsGone()) return; } - const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000); - while (Date.now() < deadline) { - const targetAlive = targetProcessGroup - ? isProcessGroupAlive(record.processGroupId) - : isPidAlive(record.pid); - if (!targetAlive) { - return; - } - await delay(100); - } - - const stillAlive = targetProcessGroup - ? isProcessGroupAlive(record.processGroupId) - : isPidAlive(record.pid); - if (!stillAlive) return; + if (await waitUntilGone(opts?.forceAfterMs ?? 2_000)) return; try { if (targetProcessGroup) { process.kill(-record.processGroupId!, "SIGKILL"); @@ -435,6 +553,14 @@ export async function terminateLocalService( } catch { // Ignore cleanup races. } + + if (await waitUntilGone(opts?.verifyAfterMs ?? 2_000)) return; + + const target = targetProcessGroup + ? `process group ${record.processGroupId}` + : `process ${record.pid}`; + const listener = record.port ? ` and listener on port ${record.port}` : ""; + throw new Error(`Failed to terminate local service ${target}${listener}`); } export async function readLocalServicePortOwner(port: number) { diff --git a/server/src/services/runtime-exposure/loopback-listener.test.ts b/server/src/services/runtime-exposure/loopback-listener.test.ts index b0a16f81782..2e25f939c8a 100644 --- a/server/src/services/runtime-exposure/loopback-listener.test.ts +++ b/server/src/services/runtime-exposure/loopback-listener.test.ts @@ -162,7 +162,7 @@ describe("diagnoseRuntimeListenerBinds against live listeners", () => { expect(diagnosis).toContain(`port ${appPort}`); // Node's hostless listen is dual-stack, so /proc shows :: and/or 0.0.0.0. expect(diagnosis).toMatch(/0\.0\.0\.0|::/); - expect(diagnosis).toContain("--bind custom --bind-host 127.0.0.1"); + expect(diagnosis).toContain("--bind loopback"); }); }); diff --git a/server/src/services/runtime-exposure/loopback-listener.ts b/server/src/services/runtime-exposure/loopback-listener.ts index 9cf825b53c3..33fbcaa1b42 100644 --- a/server/src/services/runtime-exposure/loopback-listener.ts +++ b/server/src/services/runtime-exposure/loopback-listener.ts @@ -160,6 +160,6 @@ export async function diagnoseRuntimeListenerBinds(ports: number[]): Promise { await fs.writeFile(path.join(guestDir, "dev-runner.mjs"), PRE_MANAGED_EXPOSURE_GUEST); await fs.writeFile(path.join(guestDir, "dev-runner-legacy.mjs"), ALWAYS_WILDCARD_GUEST); await fs.writeFile(path.join(guestDir, "dev-runner-wildcard-hmr.mjs"), WILDCARD_HMR_GUEST); + await fs.writeFile( + path.join(guestDir, "dev-runner-bind-conflict.mjs"), + 'process.stderr.write("local_trusted requires server.bind=loopback\\n"); process.exit(1);\n', + ); }); afterAll(async () => { @@ -474,7 +478,7 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256) })); // The launched command carries the loopback bind, replacing `--bind lan`. - expect(runtime.command).toContain("--bind custom --bind-host 127.0.0.1"); + expect(runtime.command).toContain("--bind loopback"); expect(runtime.command).not.toContain("--bind lan"); // And because the guest honoured it, both listeners are loopback-only, so @@ -530,7 +534,7 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256) // The bare code alone is what cost PAP-17254 three diagnostic cycles. expect(error!.message).toContain("listener_ownership_mismatch"); expect(error!.message).toContain("loopback"); - expect(error!.message).toContain("--bind custom --bind-host 127.0.0.1"); + expect(error!.message).toContain("--bind loopback"); }, 20_000); it("leaves a non-Paperclip service's --bind argument alone", async () => { @@ -547,7 +551,7 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256) })); expect(runtime.command).toBe(declared); - expect(runtime.command).not.toContain("--bind custom"); + expect(runtime.command).not.toContain("--bind loopback"); expect(calls.slice(0, 2)).toEqual(["reserve", "expose"]); expect(runtime.exposure?.state).toBe("ready"); }, 20_000); @@ -567,6 +571,20 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256) expect(calls).toEqual([]); expect(runtime.command).toBe(declared); }, 20_000); + + it("surfaces a deployment/bind conflict from a guest that exits during startup", async () => { + const { broker } = createBroker(); + installDeps({ broker }); + + await expect(startRuntimeServicesForWorkspaceControl(startInput({ + serviceName: "paperclip-dev", + command: guestCommand("dev-runner-bind-conflict.mjs"), + expose: LEGACY_HTTP_EXPOSE, + port: { type: "auto", envKey: "PORT" }, + }))).rejects.toThrow( + /deployment\/bind conflict: local_trusted requires server\.bind=loopback.*output: local_trusted requires server\.bind=loopback/s, + ); + }, 20_000); }); describe("readiness probes loopback for an exposed runtime (PAP-17256)", () => { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 0e4656c13fc..d1a4f7801c3 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -52,6 +52,7 @@ import { findAdoptableLocalService, isLocalServiceProcessOwnedBy, isLocalServiceProcessInWorkspace, + openLocalServiceLogFile, readLocalServiceProcessCwd, readLocalServicePortOwner, removeLocalServiceRegistryRecord, @@ -475,7 +476,7 @@ type ProcessOutputAccumulator = { * broker fake handles the removal rather than the real host broker. */ export async function resetRuntimeServicesForTests( - opts: { terminateProcesses?: boolean } = {}, + opts: { terminateProcesses?: boolean; simulateSupervisorExit?: boolean } = {}, ) { if (opts.terminateProcesses) { for (const serviceId of [...runtimeServicesById.keys()]) { @@ -484,6 +485,13 @@ export async function resetRuntimeServicesForTests( } for (const record of runtimeServicesById.values()) { clearIdleTimer(record); + if (opts.simulateSupervisorExit) { + // A real supervisor exit closes its side of every inherited pipe. Tests + // use this to prove surviving request-logging services do not depend on + // Paperclip keeping an anonymous stdio peer alive. + record.child?.stdout?.destroy(); + record.child?.stderr?.destroy(); + } } runtimeServicesById.clear(); runtimeServicesByReuseKey.clear(); @@ -2757,6 +2765,20 @@ function quoteShellArg(value: string) { return `'${value.replace(/'/g, `'\\''`)}'`; } +const BUILTIN_WORKSPACE_PROVISION_COMMAND = "bash ./scripts/provision-worktree.sh"; + +function resolveWorkspaceProvisionCommand( + strategy: Record, + repoRoot: string, +) { + const configuredCommand = asString(strategy.provisionCommand, "").trim(); + if (configuredCommand) return configuredCommand; + + return existsSync(path.join(repoRoot, "scripts", "provision-worktree.sh")) + ? BUILTIN_WORKSPACE_PROVISION_COMMAND + : ""; +} + function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) { const patterns = [ /^(?(?:bash|sh|zsh)\s+)(?["']?)(?\.\/[^"'\s]+)\k(?(?:\s.*)?)$/s, @@ -2953,7 +2975,7 @@ async function provisionExecutionWorktree(input: { created: boolean; recorder?: WorkspaceOperationRecorder | null; }) { - const provisionCommand = asString(input.strategy.provisionCommand, "").trim(); + const provisionCommand = resolveWorkspaceProvisionCommand(input.strategy, input.repoRoot); if (!provisionCommand) return; const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot); @@ -3434,22 +3456,20 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: { }); realized.warnings = [...repairWarnings, ...baseRefreshWarnings, ...baseDrift.warnings]; realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha; - if (provisionCommand) { - await provisionExecutionWorktree({ - strategy: { - type: "git_worktree", - provisionCommand, - }, - base: input.base, - repoRoot, - worktreePath: realized.worktreePath ?? cwd, - branchName: realized.branchName ?? "", - issue: input.issue, - agent: input.agent, - created: false, - recorder: input.recorder ?? null, - }); - } + await provisionExecutionWorktree({ + strategy: { + type: "git_worktree", + ...(provisionCommand ? { provisionCommand } : {}), + }, + base: input.base, + repoRoot, + worktreePath: realized.worktreePath ?? cwd, + branchName: realized.branchName ?? "", + issue: input.issue, + agent: input.agent, + created: false, + recorder: input.recorder ?? null, + }); return realized; } @@ -5139,17 +5159,12 @@ export function resolveRuntimeProvisionCommand(input: { const stateDir = path.join(input.workspace.cwd, ".paperclip"); const manifestPath = path.join(stateDir, "seed-manifest.json"); - 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", ); - let needsSeed = existsSync(pendingMarker) && !existsSync(completeMarker); - if (existsSync(manifestPath)) { - needsSeed = !hasVerifiedWorktreeSeedManifest(manifestPath); - } + const needsSeed = !existsSync(manifestPath) || !hasVerifiedWorktreeSeedManifest(manifestPath); if (!needsSeed || !existsSync(provisionScript)) { return ""; } @@ -5535,7 +5550,7 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P // still rejected by the broker unless /proc proves loopback-only listeners. // // Three independent layers force the loopback bind, because a guest checkout - // can be arbitrarily old (PAP-17256): the `--bind custom --bind-host` argv + // can be arbitrarily old (PAP-17256): the `--bind loopback` argv // added above, these env vars for a runner that reads them, and HOST for one // old enough to ignore both and infer its bind mode from HOST alone. env.PAPERCLIP_BIND = RUNTIME_EXPOSURE_BIND_MODE; @@ -5748,12 +5763,21 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P } const shell = resolveShell(); - const child = spawn(shell, ["-lc", command], { - cwd: serviceCwd, - env, - detached: process.platform !== "win32", - stdio: ["ignore", "pipe", "pipe"], - }); + const serviceLog = await openLocalServiceLogFile(serviceKey); + let child: ChildProcess; + try { + child = spawn(shell, ["-lc", command], { + cwd: serviceCwd, + env, + detached: process.platform !== "win32", + // The service receives duplicate append-only file descriptors. Closing + // Paperclip (or this parent handle below) cannot strand a request logger + // on an orphaned socketpair during startup reconciliation. + stdio: ["ignore", serviceLog.handle.fd, serviceLog.handle.fd], + }); + } finally { + await serviceLog.handle.close(); + } record.child = child; record.providerRef = child.pid ? String(child.pid) : null; record.processGroupId = child.pid ?? null; @@ -5763,24 +5787,23 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P }); }); const earlyExitPromise = new Promise((_, reject) => { - child.once("exit", (code, signal) => { + // `close` follows `exit` after the child's inherited stdout/stderr file + // descriptors are closed. Waiting for it makes the startup log excerpt + // deterministic instead of racing the final validation line. + child.once("close", (code, signal) => { reject(new Error( `service process exited before readiness (code ${code ?? "unknown"}, signal ${signal ?? "none"})`, )); }); }); - let stderrExcerpt = ""; - let stdoutExcerpt = ""; - child.stdout?.on("data", async (chunk) => { - const text = String(chunk); - stdoutExcerpt = (stdoutExcerpt + text).slice(-4096); - if (input.onLog) await input.onLog("stdout", `[service:${serviceName}] ${text}`); - }); - child.stderr?.on("data", async (chunk) => { - const text = String(chunk); - stderrExcerpt = (stderrExcerpt + text).slice(-4096); - if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`); - }); + const readServiceOutputExcerpt = async () => { + try { + const contents = await fs.readFile(serviceLog.logPath); + return contents.subarray(Math.max(serviceLog.startOffset, contents.length - 4096)).toString("utf8"); + } catch { + return ""; + } + }; if (child.pid) { await writeLocalServiceRegistryRecord({ @@ -5907,6 +5930,10 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P record.healthStatus = "healthy"; record.lastUsedAt = new Date().toISOString(); record.stoppedAt = null; + const serviceOutputExcerpt = await readServiceOutputExcerpt(); + if (serviceOutputExcerpt && input.onLog) { + await input.onLog("stdout", `[service:${serviceName}] ${serviceOutputExcerpt}`); + } await touchLocalServiceRegistryRecord(record.serviceKey, { runtimeServiceId: record.id, lastSeenAt: record.lastUsedAt, @@ -5914,18 +5941,20 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P }).catch(async (err) => { releasePortReservation(reservedPort); releasePortReservation(claimedIdentityPort); - const failureMessage = err instanceof Error ? err.message : String(err); + const failureMessage = err instanceof Error ? err.message : String(err); + const serviceOutputExcerpt = await readServiceOutputExcerpt(); const bindCollision = !exposureConfig && ( err instanceof RuntimeServicePortBindCollision || Boolean( port && (input.allowFixedPortFallback || portType === "auto") - && /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${stderrExcerpt}`), + && /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${serviceOutputExcerpt}`), ) ); if (child.pid) { await terminateLocalService({ pid: child.pid, processGroupId: child.pid, + port, }); } await cleanupRecordExposure(record, { preserveFailure: true }); @@ -5938,8 +5967,14 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P await persistRuntimeServiceRecord(record.db, record).catch(() => undefined); } if (bindCollision && port) throw new RuntimeServicePortBindCollision(port); + const deploymentBindConflict = /local_trusted requires server\.bind=loopback/i.test( + `${failureMessage}\n${serviceOutputExcerpt}`, + ); + const actionableFailure = deploymentBindConflict + ? `${failureMessage} | deployment/bind conflict: local_trusted requires server.bind=loopback; the managed runtime requested an incompatible bind mode` + : failureMessage; throw new Error( - `Failed to start runtime service "${serviceName}": ${failureMessage}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`, + `Failed to start runtime service "${serviceName}": ${actionableFailure}${serviceOutputExcerpt ? ` | output: ${serviceOutputExcerpt.trim()}` : ""}`, ); }); @@ -6125,19 +6160,14 @@ async function stopRuntimeService(serviceId: string) { const record = runtimeServicesById.get(serviceId); if (!record) return; clearIdleTimer(record); - record.status = "stopped"; - record.healthStatus = "unknown"; - record.lastUsedAt = new Date().toISOString(); - record.stoppedAt = new Date().toISOString(); - runtimeServicesById.delete(serviceId); - if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) { - runtimeServicesByReuseKey.delete(record.reuseKey); - } + // Remove any public exposure first, but keep the process registered and the + // row non-stopped until verified termination succeeds. await cleanupRecordExposure(record); if (record.child && record.child.pid) { await terminateLocalService({ pid: record.child.pid, processGroupId: record.processGroupId ?? record.child.pid, + port: record.port, }); } else if (record.providerRef) { const pid = Number.parseInt(record.providerRef, 10); @@ -6145,9 +6175,18 @@ async function stopRuntimeService(serviceId: string) { await terminateLocalService({ pid, processGroupId: record.processGroupId, + port: record.port, }); } } + record.status = "stopped"; + record.healthStatus = "unknown"; + record.lastUsedAt = new Date().toISOString(); + record.stoppedAt = new Date().toISOString(); + runtimeServicesById.delete(serviceId); + if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) { + runtimeServicesByReuseKey.delete(record.reuseKey); + } await removeLocalServiceRegistryRecord(record.serviceKey); await persistRuntimeServiceRecord(record.db, record); } diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index db4e9fffe79..be97f403c2f 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -8,6 +8,11 @@ import { type PaperclipConfig, } from "@paperclipai/shared"; import { updateEnvFileContents, writeEnvFileAtomicallyIfChanged } from "@paperclipai/shared/env-file"; +import { + readWorktreePortRegistry, + withWorktreePortRegistryLockSync, + writeWorktreePortRegistry, +} from "@paperclipai/shared/worktree-port-registry"; import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; import { rewriteUrlPort } from "./url-utils.js"; @@ -99,89 +104,6 @@ type WorktreeRuntimeContext = { secretsKeyFilePath: string; }; -type WorktreePortRegistry = { - version: 1; - configPaths: string[]; -}; - -const WORKTREE_PORT_REGISTRY_FILE = "worktree-port-reservations.json"; -const WORKTREE_PORT_REGISTRY_LOCK_DIR = ".worktree-port-reservations.lock"; -const WORKTREE_PORT_REGISTRY_LOCK_STALE_MS = 5_000; -const WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS = 10_000; -const sleepSyncBuffer = new Int32Array(new SharedArrayBuffer(4)); - -function sleepSync(durationMs: number): void { - Atomics.wait(sleepSyncBuffer, 0, 0, durationMs); -} - -function withWorktreePortRegistryLock(homeDir: string, run: () => T): T { - fs.mkdirSync(homeDir, { recursive: true }); - const lockPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_LOCK_DIR); - const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS; - - while (true) { - try { - fs.mkdirSync(lockPath); - break; - } catch (error) { - const code = error instanceof Error && "code" in error ? error.code : null; - if (code !== "EEXIST") throw error; - - try { - const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; - if (ageMs > WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) { - fs.rmSync(lockPath, { recursive: true, force: true }); - continue; - } - } catch { - continue; - } - - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for worktree port reservation lock at ${lockPath}`); - } - sleepSync(25); - } - } - - try { - return run(); - } finally { - fs.rmSync(lockPath, { recursive: true, force: true }); - } -} - -function readWorktreePortRegistry(homeDir: string): Set { - const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE); - if (!fs.existsSync(registryPath)) return new Set(); - - try { - const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")) as Partial; - if (parsed.version !== 1 || !Array.isArray(parsed.configPaths)) return new Set(); - return new Set( - parsed.configPaths - .filter((configPath): configPath is string => typeof configPath === "string" && configPath.length > 0) - .map((configPath) => path.resolve(configPath)), - ); - } catch { - return new Set(); - } -} - -function writeWorktreePortRegistry(homeDir: string, configPaths: Iterable): void { - const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE); - const persistedPaths = Array.from(new Set(Array.from(configPaths, (configPath) => path.resolve(configPath)))) - .filter((configPath) => fs.existsSync(configPath)) - .sort(); - const registry: WorktreePortRegistry = { - version: 1, - configPaths: persistedPaths, - }; - const temporaryPath = `${registryPath}.${process.pid}.tmp`; - fs.writeFileSync(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 }); - fs.renameSync(temporaryPath, registryPath); -} - function resolveWorktreeRuntimeContext( env: NodeJS.ProcessEnv, overrideConfigPath?: string, @@ -354,7 +276,6 @@ function collectSiblingWorktreePorts( serverPorts.add(siblingConfig.server.port); } if ( - siblingConfig.database.mode === "embedded-postgres" && Number.isInteger(siblingConfig.database.embeddedPostgresPort) && siblingConfig.database.embeddedPostgresPort > 0 ) { @@ -542,7 +463,7 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { let repairedConfig = false; if (fs.existsSync(context.configPath)) { try { - const runtimeConfig = withWorktreePortRegistryLock(context.homeDir, () => { + const runtimeConfig = withWorktreePortRegistryLockSync(context.homeDir, () => { const parsed = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; let selectedConfig = parsed; const registeredConfigPaths = readWorktreePortRegistry(context.homeDir); @@ -671,4 +592,3 @@ export function maybePersistWorktreeRuntimePorts(input: { writeConfigFile(context.configPath, config); } } - From 81612442840976ab9c16ccaadf0bbafb9debfca2 Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Wed, 19 Aug 2026 13:39:13 -0700 Subject: [PATCH 38/39] feat(sandbox): add opt-in duplex command-stream foundation (capability, protocol, bounded host route, frame codec) (#11738) ## Thinking Path > - Paperclip provides a control plane for companies that run AI agents. > - Sandboxed agents need a safe execution path for persistent command streams. > - The existing callback transport does not provide a bounded, generic duplex route. > - The host must control capability access, route identity, protocol limits, and close behavior. > - This pull request adds an opt-in duplex command-stream foundation across the sandbox layers. > - The feature stays inert because no current provider declares the capability. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting (multiple of the above) **Problem or motivation** Sandbox command execution needs a persistent host-to-sandbox stream. The current callback bridge uses a file transport and does not provide this generic route. **Proposed solution** Add a fail-closed provider capability, generic worker protocol messages, a host-owned bounded route, cross-layer service mediation, and a versioned newline-delimited frame codec. **Alternatives considered** Keep the file transport and add feature-specific commands. This does not provide one reusable duplex contract or host-owned route bounds. **Roadmap alignment** This work supports the completed Cloud / Sandbox agents roadmap area and the safe autonomy goal in the product definition. **Additional context** The change passed a two-stage security review. The final code review verdict was approve after fixes for active-stream bounds and service-layer capability mediation. ## What Changed - Add the opt-in `duplexCommandStream` provider capability with fail-closed narrowing. - Add duplex open, write, stop, and close requests and data and exit notifications to the plugin worker protocol. - Add a host-owned route with bounds for chunk size, cumulative bytes, lifetime, protocol errors, pending requests, and pre-bind buffering. - Add close acknowledgement handling with worker retirement when the close remains unconfirmed. - Wire `openDuplexChannel` through the execution target, runtime service, and plugin worker. - Add a versioned frame codec with shared wire-compatibility vectors and split UTF-8 handling. ## Verification - `server/src/__tests__/plugin-worker-manager-duplex.test.ts` passes 18 tests. - `server/src/__tests__/environment-execution-target-duplex.test.ts` passes 11 tests. - `packages/adapter-utils/src/duplex-frame-codec.test.ts` passes 38 tests. - `server/src/__tests__/sandbox-capability-contract.test.ts` passes 15 tests. - Setup-token pseudo-terminal regression tests pass 47 tests. - Server TypeScript check passes. - Continuous integration will run the full required test, typecheck, build, and policy checks. ## Risks - Providers that opt into the capability must implement the complete worker protocol. - Route limit defaults can close a stream when a workload exceeds the configured bounds. - The capability remains disabled for current providers, so current production behavior does not change. ## Model Used OpenAI GPT-5 (`gpt-5`), with tool use and code execution. The model reviewed and prepared this pull request from the supplied implementation and verification record. ## 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/command-managed-runtime.ts | 37 ++ .../src/duplex-frame-codec.test.ts | 201 ++++++ .../adapter-utils/src/duplex-frame-codec.ts | 338 ++++++++++ .../src/duplex-frame-vectors.json | 358 +++++++++++ .../adapter-utils/src/execution-target.ts | 2 + packages/plugins/sdk/src/define-plugin.ts | 33 + packages/plugins/sdk/src/index.ts | 2 + .../sdk/src/protocol.duplex-channel.test.ts | 156 +++++ packages/plugins/sdk/src/protocol.ts | 114 ++++ packages/plugins/sdk/src/worker-rpc-host.ts | 48 ++ .../plugins/sdk/tests/worker-rpc-host.test.ts | 188 ++++++ packages/plugins/sdk/vitest.config.ts | 2 +- packages/shared/src/types/plugin.ts | 11 + packages/shared/src/validators/plugin.ts | 1 + ...ment-execution-target-capabilities.test.ts | 2 + ...nvironment-execution-target-duplex.test.ts | 379 ++++++++++++ .../fixtures/plugin-worker-duplex-channel.cjs | 174 ++++++ .../plugin-worker-manager-duplex.test.ts | 505 +++++++++++++++ .../sandbox-capability-contract.test.ts | 42 ++ .../services/environment-execution-target.ts | 15 + server/src/services/environment-runtime.ts | 140 ++++- server/src/services/plugin-worker-manager.ts | 581 +++++++++++++++++- 22 files changed, 3304 insertions(+), 25 deletions(-) create mode 100644 packages/adapter-utils/src/duplex-frame-codec.test.ts create mode 100644 packages/adapter-utils/src/duplex-frame-codec.ts create mode 100644 packages/adapter-utils/src/duplex-frame-vectors.json create mode 100644 packages/plugins/sdk/src/protocol.duplex-channel.test.ts create mode 100644 server/src/__tests__/environment-execution-target-duplex.test.ts create mode 100644 server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs create mode 100644 server/src/__tests__/plugin-worker-manager-duplex.test.ts diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 9c39ed3d069..22a458b1352 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -18,6 +18,35 @@ import type { RunProcessResult } from "./server-utils.js"; import type { RuntimeProgressSink, RuntimeStatusSink } from "./runtime-progress.js"; import type { RuntimeSpanRunner } from "./acpx-engine/startup-timing.js"; +/** + * Input for a duplex channel open. The caller supplies only the command line + * the sandbox runs as the channel child process. The runner adds the lease + * scope from its own closure. This type is separate from the worker manager's + * `DuplexChannelOpenInput`, which also carries the lease scope fields. + */ +export interface DuplexChannelOpenInput { + command: string; +} + +/** + * A persistent bidirectional channel to one long-lived command in the sandbox. + * The caller writes raw input bytes, reads streamed output, and stops or closes + * the channel. This is the cross-layer channel type: the runner returns it, and + * the sandbox driver adapts the worker manager's host session to it. + */ +export interface CommandManagedDuplexChannel { + /** Writes raw input bytes to the channel. */ + write(data: string): void; + /** Registers the one data listener. The channel streams each raw chunk in order. */ + onData(listener: (chunk: string) => void): void; + /** Registers the one exit listener. The channel calls it one time with the exit. */ + onExit(listener: (exit: { exitCode: number | null }) => void): void; + /** Stops the child process. Safe to call more than one time. */ + stop(): void; + /** Closes the channel and releases the route. Safe to call more than one time. */ + close(): Promise; +} + export interface CommandManagedRuntimeRunner { /** * True when the provider verified the concurrent-sync opt-in. A native runner @@ -76,6 +105,14 @@ export interface CommandManagedRuntimeRunner { syncIn?(operations: SandboxSyncOperation[]): Promise; /** Optional native outbound file transfer. See {@link syncIn}. */ syncOut?(operations: SandboxSyncOperation[]): Promise; + /** + * Optional persistent duplex channel. Present only when the sandbox provider's + * effective capability grants `duplexCommandStream`. The runner opens one + * bidirectional channel to a long-lived command in the sandbox. The SSH runner + * and every provider without the capability omit the member, so a caller gates + * on its presence in the same style as {@link syncIn}/{@link syncOut}. + */ + openDuplexChannel?(input: DuplexChannelOpenInput): Promise; } export interface CommandManagedRuntimeSpec { diff --git a/packages/adapter-utils/src/duplex-frame-codec.test.ts b/packages/adapter-utils/src/duplex-frame-codec.test.ts new file mode 100644 index 00000000000..539d4898ec5 --- /dev/null +++ b/packages/adapter-utils/src/duplex-frame-codec.test.ts @@ -0,0 +1,201 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + DEFAULT_MAX_DUPLEX_FRAME_BYTES, + DUPLEX_FRAME_VERSION, + DuplexFrameDecoder, + decodeDuplexLine, + encodeDuplexFrame, + type DuplexDecodeResult, + type DuplexFrame, +} from "./duplex-frame-codec.js"; + +// One expected decode result in the fixture: either a decoded frame or the code +// of a protocol error. The codec must never throw on the read path. +type ExpectedResult = { frame: DuplexFrame } | { error: string }; + +interface Vector { + name: string; + category: "valid" | "invalid" | "partial" | "oversized" | "versionMismatch"; + bytes: string; + splitByteOffsets?: number[]; + maxFrameBytes?: number; + roundTrip?: boolean; + expected: ExpectedResult[]; +} + +interface Fixture { + frameVersion: number; + defaultMaxFrameBytes: number; + vectors: Vector[]; +} + +const fixturePath = fileURLToPath( + new URL("./duplex-frame-vectors.json", import.meta.url), +); +const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + +// Cut one UTF-8 byte stream into chunks at the byte offsets. The decoder must +// keep partial bytes between chunks, so the split can fall inside a multi-byte +// character. +function toChunks(bytes: string, offsets: number[] | undefined): Buffer[] { + const buffer = Buffer.from(bytes, "utf8"); + if (!offsets || offsets.length === 0) return [buffer]; + const bounds = [0, ...offsets, buffer.length]; + const chunks: Buffer[] = []; + for (let i = 0; i < bounds.length - 1; i += 1) { + chunks.push(buffer.subarray(bounds[i], bounds[i + 1])); + } + return chunks; +} + +function pushAll(decoder: DuplexFrameDecoder, chunks: Buffer[]): DuplexDecodeResult[] { + const results: DuplexDecodeResult[] = []; + for (const chunk of chunks) results.push(...decoder.push(chunk)); + return results; +} + +function assertMatches(results: DuplexDecodeResult[], expected: ExpectedResult[]): void { + expect(results).toHaveLength(expected.length); + results.forEach((result, index) => { + const want = expected[index]; + if ("frame" in want) { + expect(result.ok).toBe(true); + if (result.ok) expect(result.frame).toEqual(want.frame); + } else { + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe(want.error); + } + }); +} + +describe("duplex frame codec fixture", () => { + it("the fixture version matches the codec version", () => { + expect(fixture.frameVersion).toBe(DUPLEX_FRAME_VERSION); + expect(fixture.defaultMaxFrameBytes).toBe(DEFAULT_MAX_DUPLEX_FRAME_BYTES); + }); + + it("every category has at least one vector", () => { + const categories = new Set(fixture.vectors.map((vector) => vector.category)); + for (const category of ["valid", "invalid", "partial", "oversized", "versionMismatch"]) { + expect(categories).toContain(category); + } + }); + + for (const vector of fixture.vectors) { + it(`decodes the ${vector.name} vector`, () => { + const decoder = new DuplexFrameDecoder( + vector.maxFrameBytes ? { maxFrameBytes: vector.maxFrameBytes } : undefined, + ); + const chunks = toChunks(vector.bytes, vector.splitByteOffsets); + const results = pushAll(decoder, chunks); + assertMatches(results, vector.expected); + }); + } +}); + +describe("round-trip", () => { + const validVectors = fixture.vectors.filter((vector) => vector.roundTrip); + + it("has round-trip vectors for every frame type", () => { + const types = new Set( + validVectors.flatMap((vector) => + vector.expected.flatMap((result) => + "frame" in result ? [result.frame.type] : [], + ), + ), + ); + for (const type of ["request", "response", "ready", "heartbeat", "close", "error"]) { + expect(types).toContain(type); + } + }); + + for (const vector of validVectors) { + it(`re-encodes the ${vector.name} frame to the same value`, () => { + const want = vector.expected[0]; + expect("frame" in want).toBe(true); + if (!("frame" in want)) return; + const encoded = encodeDuplexFrame(want.frame); + // Encode writes exactly one line: it ends with one newline and holds no + // interior newline, so one frame stays on one line. + expect(encoded.endsWith("\n")).toBe(true); + expect(encoded.slice(0, -1)).not.toContain("\n"); + const decoded = decodeDuplexLine(encoded.slice(0, -1)); + expect(decoded.ok).toBe(true); + if (decoded.ok) expect(decoded.frame).toEqual(want.frame); + }); + } +}); + +describe("streaming decoder behavior", () => { + it("emits nothing until a full line arrives, then the complete frame", () => { + const decoder = new DuplexFrameDecoder(); + const line = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); + const bytes = Buffer.from(line, "utf8"); + const first = decoder.push(bytes.subarray(0, 3)); + expect(first).toHaveLength(0); + const second = decoder.push(bytes.subarray(3)); + expect(second).toHaveLength(1); + expect(second[0].ok).toBe(true); + }); + + it("keeps a multi-byte UTF-8 sequence valid across a chunk boundary", () => { + const decoder = new DuplexFrameDecoder(); + const frame: DuplexFrame = { + version: DUPLEX_FRAME_VERSION, + type: "request", + id: "req-emoji", + method: "POST", + path: "/x", + query: "", + headers: {}, + body: "😀", + }; + const bytes = Buffer.from(encodeDuplexFrame(frame), "utf8"); + // Split inside the four-byte emoji, right before a continuation byte. + let cut = -1; + for (let i = 1; i < bytes.length; i += 1) { + if ((bytes[i] & 0xc0) === 0x80) { + cut = i; + break; + } + } + expect(cut).toBeGreaterThan(0); + const results = [ + ...decoder.push(bytes.subarray(0, cut)), + ...decoder.push(bytes.subarray(cut)), + ]; + expect(results).toHaveLength(1); + expect(results[0].ok).toBe(true); + if (results[0].ok) expect(results[0].frame).toEqual(frame); + }); + + it("rejects an oversized frame with a protocol error, then resynchronizes", () => { + const decoder = new DuplexFrameDecoder({ maxFrameBytes: 32 }); + const flood = `${"z".repeat(100)}\n`; + const good = encodeDuplexFrame({ version: DUPLEX_FRAME_VERSION, type: "heartbeat" }); + const results = decoder.push(Buffer.from(flood + good, "utf8")); + expect(results).toHaveLength(2); + expect(results[0].ok).toBe(false); + if (!results[0].ok) expect(results[0].error.code).toBe("frame_too_large"); + expect(results[1].ok).toBe(true); + }); + + it("rejects a version-mismatch frame with a protocol error", () => { + const decoder = new DuplexFrameDecoder(); + const results = decoder.push( + Buffer.from(`${JSON.stringify({ version: 999, type: "heartbeat" })}\n`, "utf8"), + ); + expect(results).toHaveLength(1); + expect(results[0].ok).toBe(false); + if (!results[0].ok) expect(results[0].error.code).toBe("version_mismatch"); + }); + + it("never throws on a malformed read; it returns a protocol error", () => { + const decoder = new DuplexFrameDecoder(); + expect(() => decoder.push(Buffer.from("this is not json\n", "utf8"))).not.toThrow(); + const results = decoder.push(Buffer.from("still not json\n", "utf8")); + expect(results[0].ok).toBe(false); + }); +}); diff --git a/packages/adapter-utils/src/duplex-frame-codec.ts b/packages/adapter-utils/src/duplex-frame-codec.ts new file mode 100644 index 00000000000..2cc4d57c988 --- /dev/null +++ b/packages/adapter-utils/src/duplex-frame-codec.ts @@ -0,0 +1,338 @@ +/** + * Versioned frame codec for the sandbox duplex channel. + * + * The channel carries newline-delimited JSON frames. One frame is one line. The + * host and the generated gateway each hold a copy of this codec. A shared + * fixture file (`duplex-frame-vectors.json`) proves the two copies stay wire + * compatible: both copies decode the same bytes to the same frames. + * + * The codec has two sides: + * - encode: turn a frame object into one line of JSON with a trailing newline. + * - decode: turn a byte stream into frames. The streaming decoder keeps partial + * bytes between chunks, so a frame split across chunks and a multi-byte UTF-8 + * sequence split across chunks both decode correctly. + * + * The decoder never throws on the read path. A malformed, oversized, or + * version-mismatch frame becomes a protocol-error result, not an exception. This + * keeps one bad frame from crashing the read loop. + */ + +/** The wire version this codec reads and writes. */ +export const DUPLEX_FRAME_VERSION = 1; + +/** + * The default maximum size of one frame, in bytes. The decoder rejects a longer + * frame with a `frame_too_large` protocol error. The value matches the per-chunk + * character bound of the host duplex route. + */ +export const DEFAULT_MAX_DUPLEX_FRAME_BYTES = 1_000_000; + +const NEWLINE_BYTE = 0x0a; +const EMPTY = Buffer.alloc(0); + +/** The frame type strings. One value goes in the `type` field of every frame. */ +export const DUPLEX_FRAME_TYPES = { + request: "request", + response: "response", + ready: "ready", + heartbeat: "heartbeat", + close: "close", + error: "error", +} as const; + +/** The outcome of a response frame. A loss response carries a non-completed outcome. */ +export type DuplexResponseOutcome = "completed" | "indeterminate" | "unavailable"; + +/** A request frame. The gateway forwards it to the host API path. */ +export interface DuplexRequestFrame { + version: number; + type: "request"; + id: string; + method: string; + path: string; + query: string; + headers: Record; + body: string; +} + +/** A response frame. The host returns it for one request id. */ +export interface DuplexResponseFrame { + version: number; + type: "response"; + id: string; + status: number; + headers: Record; + body: string; + outcome: DuplexResponseOutcome; +} + +/** + * The READY control frame. The gateway sends it one time after it validates its + * local listener address. The `address` field carries that validated address. + */ +export interface DuplexReadyFrame { + version: number; + type: "ready"; + address: string; +} + +/** The heartbeat control frame. Each side sends it on an interval to prove liveness. */ +export interface DuplexHeartbeatFrame { + version: number; + type: "heartbeat"; +} + +/** The orderly close control frame. A side sends it to end the channel cleanly. */ +export interface DuplexCloseFrame { + version: number; + type: "close"; +} + +/** + * The protocol-error control frame. A peer sends it to report a bad frame. This + * frame is distinct from a decode-time protocol error: the decoder produces a + * {@link DuplexProtocolError} result, while a peer sends this frame on the wire. + */ +export interface DuplexErrorFrame { + version: number; + type: "error"; + code: string; + message?: string; +} + +/** Any frame the codec reads or writes. */ +export type DuplexFrame = + | DuplexRequestFrame + | DuplexResponseFrame + | DuplexReadyFrame + | DuplexHeartbeatFrame + | DuplexCloseFrame + | DuplexErrorFrame; + +/** The reason the decoder rejected one line. */ +export type DuplexProtocolErrorCode = + | "malformed_frame" + | "unknown_type" + | "version_mismatch" + | "frame_too_large"; + +/** A decode-time protocol error. The read path returns it; it never throws. */ +export interface DuplexProtocolError { + code: DuplexProtocolErrorCode; + message: string; +} + +/** One decode result: a valid frame, or a protocol error. */ +export type DuplexDecodeResult = + | { ok: true; frame: DuplexFrame } + | { ok: false; error: DuplexProtocolError }; + +const RESPONSE_OUTCOMES: ReadonlySet = new Set([ + "completed", + "indeterminate", + "unavailable", +]); + +function ok(frame: DuplexFrame): DuplexDecodeResult { + return { ok: true, frame }; +} + +function fail(code: DuplexProtocolErrorCode, message: string): DuplexDecodeResult { + return { ok: false, error: { code, message } }; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record { + if (!isPlainObject(value)) return false; + for (const entry of Object.values(value)) { + if (typeof entry !== "string") return false; + } + return true; +} + +/** + * Encode one frame to a single line of JSON with a trailing newline. `JSON.stringify` + * escapes any newline inside a string value, so the returned line holds no + * interior newline. This keeps one frame on one line. + */ +export function encodeDuplexFrame(frame: DuplexFrame): string { + return `${JSON.stringify(frame)}\n`; +} + +/** + * Decode one line (no trailing newline) to a frame or a protocol error. The + * streaming decoder calls this for each complete line. It is exported so a + * caller with its own line splitter can reuse the same validation. + * + * The check order matters. A parseable frame with the wrong version becomes a + * `version_mismatch`, so the version check runs before the type check. + */ +export function decodeDuplexLine(line: string | Buffer): DuplexDecodeResult { + const text = typeof line === "string" ? line : line.toString("utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return fail("malformed_frame", "frame is not valid JSON"); + } + if (!isPlainObject(parsed)) { + return fail("malformed_frame", "frame is not a JSON object"); + } + if (parsed.version !== DUPLEX_FRAME_VERSION) { + return fail( + "version_mismatch", + `frame version ${String(parsed.version)} is not ${DUPLEX_FRAME_VERSION}`, + ); + } + return validateFrame(parsed); +} + +function validateFrame(frame: Record): DuplexDecodeResult { + switch (frame.type) { + case "request": + return validateRequest(frame); + case "response": + return validateResponse(frame); + case "ready": + return validateReady(frame); + case "heartbeat": + return validateHeartbeat(frame); + case "close": + return validateClose(frame); + case "error": + return validateError(frame); + default: + return fail("unknown_type", `unknown frame type ${JSON.stringify(frame.type)}`); + } +} + +function validateRequest(frame: Record): DuplexDecodeResult { + if ( + typeof frame.id !== "string" || + typeof frame.method !== "string" || + typeof frame.path !== "string" || + typeof frame.query !== "string" || + typeof frame.body !== "string" || + !isStringRecord(frame.headers) + ) { + return fail("malformed_frame", "request frame has a missing or wrong-typed field"); + } + return ok(frame as unknown as DuplexRequestFrame); +} + +function validateResponse(frame: Record): DuplexDecodeResult { + if ( + typeof frame.id !== "string" || + typeof frame.status !== "number" || + typeof frame.body !== "string" || + !isStringRecord(frame.headers) || + typeof frame.outcome !== "string" || + !RESPONSE_OUTCOMES.has(frame.outcome) + ) { + return fail("malformed_frame", "response frame has a missing or wrong-typed field"); + } + return ok(frame as unknown as DuplexResponseFrame); +} + +function validateReady(frame: Record): DuplexDecodeResult { + if (typeof frame.address !== "string") { + return fail("malformed_frame", "ready frame has a missing or wrong-typed address"); + } + return ok(frame as unknown as DuplexReadyFrame); +} + +function validateHeartbeat(frame: Record): DuplexDecodeResult { + return ok(frame as unknown as DuplexHeartbeatFrame); +} + +function validateClose(frame: Record): DuplexDecodeResult { + return ok(frame as unknown as DuplexCloseFrame); +} + +function validateError(frame: Record): DuplexDecodeResult { + if (typeof frame.code !== "string") { + return fail("malformed_frame", "error frame has a missing or wrong-typed code"); + } + if (frame.message !== undefined && typeof frame.message !== "string") { + return fail("malformed_frame", "error frame has a wrong-typed message"); + } + return ok(frame as unknown as DuplexErrorFrame); +} + +/** Options for a {@link DuplexFrameDecoder}. */ +export interface DuplexFrameDecoderOptions { + /** The maximum size of one frame, in bytes. Defaults to {@link DEFAULT_MAX_DUPLEX_FRAME_BYTES}. */ + maxFrameBytes?: number; +} + +/** + * A streaming decoder for a byte stream of newline-delimited JSON frames. + * + * Call `push` with each chunk. The decoder keeps the bytes of an incomplete + * frame between calls, so a frame that spans two chunks decodes on the chunk + * that completes it. The decoder buffers raw bytes and decodes UTF-8 only on a + * complete line, so a multi-byte sequence split across chunks stays valid. The + * newline byte `0x0A` never appears inside a multi-byte UTF-8 sequence, so a + * split on that byte is always safe. + * + * The decoder enforces the maximum frame size. It rejects an oversized frame + * with a `frame_too_large` protocol error, then discards bytes up to the next + * newline to resynchronize. It never throws on the read path. + */ +export class DuplexFrameDecoder { + private buffer: Buffer = EMPTY; + private discarding = false; + private readonly maxFrameBytes: number; + + constructor(options: DuplexFrameDecoderOptions = {}) { + this.maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_DUPLEX_FRAME_BYTES; + } + + /** Feed one chunk. Return the frames and protocol errors that complete on it. */ + push(chunk: Buffer | Uint8Array | string): DuplexDecodeResult[] { + const incoming = + typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk); + this.buffer = + this.buffer.length === 0 ? incoming : Buffer.concat([this.buffer, incoming]); + + const results: DuplexDecodeResult[] = []; + for (;;) { + if (this.discarding) { + // Drop the tail of an oversized frame until the next newline. + const newlineIndex = this.buffer.indexOf(NEWLINE_BYTE); + if (newlineIndex === -1) { + this.buffer = EMPTY; + break; + } + this.buffer = this.buffer.subarray(newlineIndex + 1); + this.discarding = false; + continue; + } + + const newlineIndex = this.buffer.indexOf(NEWLINE_BYTE); + if (newlineIndex === -1) { + // No complete line yet. Reject an incomplete frame that already passed + // the size bound, then resynchronize at the next newline. + if (this.buffer.length > this.maxFrameBytes) { + results.push(fail("frame_too_large", "frame exceeds the maximum size")); + this.discarding = true; + this.buffer = EMPTY; + } + break; + } + + const line = this.buffer.subarray(0, newlineIndex); + this.buffer = this.buffer.subarray(newlineIndex + 1); + if (line.length === 0) continue; // Skip a blank line. + if (line.length > this.maxFrameBytes) { + results.push(fail("frame_too_large", "frame exceeds the maximum size")); + continue; + } + results.push(decodeDuplexLine(line)); + } + return results; + } +} diff --git a/packages/adapter-utils/src/duplex-frame-vectors.json b/packages/adapter-utils/src/duplex-frame-vectors.json new file mode 100644 index 00000000000..e1e07fa0c1c --- /dev/null +++ b/packages/adapter-utils/src/duplex-frame-vectors.json @@ -0,0 +1,358 @@ +{ + "frameVersion": 1, + "defaultMaxFrameBytes": 1000000, + "description": "Shared wire-compatibility vectors for the duplex frame codec. Every codec copy decodes the same bytes. bytes is a UTF-8 byte stream; splitByteOffsets cuts it into chunks; expected lists the ordered decode results (frame or protocol-error code).", + "vectors": [ + { + "name": "valid-request", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "request", + "id": "req-1", + "method": "GET", + "path": "/api/issues/PAP-1", + "query": "expand=comments", + "headers": { + "accept": "application/json", + "content-type": "application/json" + }, + "body": "" + } + } + ] + }, + { + "name": "valid-response-completed", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "response", + "id": "req-1", + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"ok\":true}", + "outcome": "completed" + } + } + ] + }, + { + "name": "valid-response-indeterminate", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-2\",\"status\":409,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"outcome_indeterminate\\\"}\",\"outcome\":\"indeterminate\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "response", + "id": "req-2", + "status": 409, + "headers": { + "content-type": "application/json" + }, + "body": "{\"error\":\"outcome_indeterminate\"}", + "outcome": "indeterminate" + } + } + ] + }, + { + "name": "valid-response-unavailable", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-3\",\"status\":503,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"error\\\":\\\"bridge_unavailable\\\"}\",\"outcome\":\"unavailable\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "response", + "id": "req-3", + "status": 503, + "headers": { + "content-type": "application/json" + }, + "body": "{\"error\":\"bridge_unavailable\"}", + "outcome": "unavailable" + } + } + ] + }, + { + "name": "valid-ready", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"ready\",\"address\":\"127.0.0.1:47215\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "ready", + "address": "127.0.0.1:47215" + } + } + ] + }, + { + "name": "valid-heartbeat", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"heartbeat\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "heartbeat" + } + } + ] + }, + { + "name": "valid-close", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"close\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "close" + } + } + ] + }, + { + "name": "valid-error", + "category": "valid", + "bytes": "{\"version\":1,\"type\":\"error\",\"code\":\"malformed_frame\",\"message\":\"peer reported a malformed frame\"}\n", + "roundTrip": true, + "expected": [ + { + "frame": { + "version": 1, + "type": "error", + "code": "malformed_frame", + "message": "peer reported a malformed frame" + } + } + ] + }, + { + "name": "invalid-not-json", + "category": "invalid", + "bytes": "{not valid json\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-json-array", + "category": "invalid", + "bytes": "[1,2,3]\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-json-number", + "category": "invalid", + "bytes": "42\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-unknown-type", + "category": "invalid", + "bytes": "{\"version\":1,\"type\":\"teleport\"}\n", + "expected": [ + { + "error": "unknown_type" + } + ] + }, + { + "name": "invalid-missing-id", + "category": "invalid", + "bytes": "{\"version\":1,\"type\":\"request\",\"method\":\"GET\",\"path\":\"/\",\"query\":\"\",\"headers\":{},\"body\":\"\"}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "invalid-bad-headers", + "category": "invalid", + "bytes": "{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"accept\":7},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n", + "expected": [ + { + "error": "malformed_frame" + } + ] + }, + { + "name": "partial-request-split", + "category": "partial", + "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n", + "splitByteOffsets": [ + 98 + ], + "expected": [ + { + "frame": { + "version": 1, + "type": "request", + "id": "req-1", + "method": "GET", + "path": "/api/issues/PAP-1", + "query": "expand=comments", + "headers": { + "accept": "application/json", + "content-type": "application/json" + }, + "body": "" + } + } + ] + }, + { + "name": "partial-utf8-split", + "category": "partial", + "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-utf8\",\"method\":\"POST\",\"path\":\"/api/issues/PAP-1/comments\",\"query\":\"\",\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"text\\\":\\\"héllo 😀 wörld\\\"}\"}\n", + "splitByteOffsets": [ + 177 + ], + "expected": [ + { + "frame": { + "version": 1, + "type": "request", + "id": "req-utf8", + "method": "POST", + "path": "/api/issues/PAP-1/comments", + "query": "", + "headers": { + "content-type": "application/json" + }, + "body": "{\"text\":\"héllo 😀 wörld\"}" + } + } + ] + }, + { + "name": "partial-two-frames", + "category": "partial", + "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n{\"version\":1,\"type\":\"response\",\"id\":\"req-1\",\"status\":200,\"headers\":{\"content-type\":\"application/json\"},\"body\":\"{\\\"ok\\\":true}\",\"outcome\":\"completed\"}\n", + "splitByteOffsets": [ + 7, + 199, + 343 + ], + "expected": [ + { + "frame": { + "version": 1, + "type": "request", + "id": "req-1", + "method": "GET", + "path": "/api/issues/PAP-1", + "query": "expand=comments", + "headers": { + "accept": "application/json", + "content-type": "application/json" + }, + "body": "" + } + }, + { + "frame": { + "version": 1, + "type": "response", + "id": "req-1", + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"ok\":true}", + "outcome": "completed" + } + } + ] + }, + { + "name": "partial-no-trailing-newline", + "category": "partial", + "bytes": "{\"version\":1,\"type\":\"heartbeat\"}", + "expected": [] + }, + { + "name": "oversized-complete-line", + "category": "oversized", + "maxFrameBytes": 64, + "bytes": "{\"version\":1,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"}\n", + "expected": [ + { + "error": "frame_too_large" + } + ] + }, + { + "name": "oversized-flood-then-resync", + "category": "oversized", + "maxFrameBytes": 64, + "splitByteOffsets": [ + 40 + ], + "bytes": "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy\n{\"version\":1,\"type\":\"heartbeat\"}\n", + "expected": [ + { + "error": "frame_too_large" + }, + { + "frame": { + "version": 1, + "type": "heartbeat" + } + } + ] + }, + { + "name": "version-mismatch-higher", + "category": "versionMismatch", + "bytes": "{\"version\":2,\"type\":\"request\",\"id\":\"req-1\",\"method\":\"GET\",\"path\":\"/api/issues/PAP-1\",\"query\":\"expand=comments\",\"headers\":{\"accept\":\"application/json\",\"content-type\":\"application/json\"},\"body\":\"\"}\n", + "expected": [ + { + "error": "version_mismatch" + } + ] + }, + { + "name": "version-mismatch-missing", + "category": "versionMismatch", + "bytes": "{\"type\":\"heartbeat\"}\n", + "expected": [ + { + "error": "version_mismatch" + } + ] + } + ] +} diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 9cb50e7dc02..8c1aba36343 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -116,6 +116,7 @@ export interface EffectiveSandboxCapabilities { readonly independentControlCommands: boolean; readonly incrementalSessionOutput: boolean; readonly concurrentSyncOperations: boolean; + readonly duplexCommandStream: boolean; } export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { @@ -256,6 +257,7 @@ function parseEffectiveSandboxCapabilities(value: unknown): EffectiveSandboxCapa independentControlCommands: parsed.independentControlCommands === true, incrementalSessionOutput: parsed.incrementalSessionOutput === true, concurrentSyncOperations: parsed.concurrentSyncOperations === true, + duplexCommandStream: parsed.duplexCommandStream === true, }; } diff --git a/packages/plugins/sdk/src/define-plugin.ts b/packages/plugins/sdk/src/define-plugin.ts index c8af7798390..69c9a1ca6e4 100644 --- a/packages/plugins/sdk/src/define-plugin.ts +++ b/packages/plugins/sdk/src/define-plugin.ts @@ -88,6 +88,12 @@ import type { PluginSetupTokenPtyStopParams, PluginSetupTokenPtyCloseParams, PluginSetupTokenPtyCloseResult, + PluginDuplexChannelOpenParams, + PluginDuplexChannelOpenResult, + PluginDuplexChannelWriteParams, + PluginDuplexChannelStopParams, + PluginDuplexChannelCloseParams, + PluginDuplexChannelCloseResult, } from "./protocol.js"; // --------------------------------------------------------------------------- @@ -464,6 +470,33 @@ export interface PluginDefinition { onSetupTokenPtyClose?( params: PluginSetupTokenPtyCloseParams, ): Promise; + + /** + * Called to open one persistent duplex channel. The worker registers the + * channel under the host route identifier and returns a worker session + * identifier for the data notification binding only. The worker streams data + * and the exit through worker→host notifications, never as a reply. Defining + * the four `onDuplexChannel*` hooks advertises the four methods. The host reads + * the open verb to gate the `duplexCommandStream` capability. + */ + onDuplexChannelOpen?( + params: PluginDuplexChannelOpenParams, + ): Promise; + + /** Called to write raw input to an open duplex channel, keyed by the worker session identifier. */ + onDuplexChannelWrite?(params: PluginDuplexChannelWriteParams): Promise; + + /** Called to stop an open duplex channel child, keyed by the worker session identifier. */ + onDuplexChannelStop?(params: PluginDuplexChannelStopParams): Promise; + + /** + * Called to close an open duplex channel by the host route identifier. The + * worker closes the exact channel registered under that identifier and returns + * a close acknowledgement that carries the same identifier. + */ + onDuplexChannelClose?( + params: PluginDuplexChannelCloseParams, + ): Promise; } // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index c34b1a4d240..d5482a46adc 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -84,6 +84,8 @@ export { JsonRpcCallError, SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION, SETUP_TOKEN_PTY_EXIT_NOTIFICATION, + DUPLEX_CHANNEL_DATA_NOTIFICATION, + DUPLEX_CHANNEL_EXIT_NOTIFICATION, _resetIdCounter, } from "./protocol.js"; diff --git a/packages/plugins/sdk/src/protocol.duplex-channel.test.ts b/packages/plugins/sdk/src/protocol.duplex-channel.test.ts new file mode 100644 index 00000000000..306d7646c57 --- /dev/null +++ b/packages/plugins/sdk/src/protocol.duplex-channel.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { + DUPLEX_CHANNEL_DATA_NOTIFICATION, + DUPLEX_CHANNEL_EXIT_NOTIFICATION, + type PluginDuplexChannelCloseParams, + type PluginDuplexChannelCloseResult, + type PluginDuplexChannelDataParams, + type PluginDuplexChannelExitParams, + type PluginDuplexChannelOpenParams, + type PluginDuplexChannelOpenResult, + type PluginDuplexChannelStopParams, + type PluginDuplexChannelWriteParams, +} from "./protocol.js"; + +// The generic duplex channel messages model the setup-token pseudo-terminal +// contract: open, write, stop, and close requests, plus data and exit +// notifications. The host owns the route identifier. The worker returns a worker +// session identifier that binds the data and the exit notification only. A close +// keys on the host route identifier, so a lost open reply still permits a +// host-keyed close. +// +// The messages are static types, so a valid payload assigns to its type and an +// invalid payload fails the type check. The `@ts-expect-error` directives make +// the compiler reject each invalid payload. The `tsc --noEmit` check validates +// the directives, so an invalid payload that the type accepts fails the build. + +describe("duplex channel request schemas", () => { + it("accepts a valid open request and its reply", () => { + const open: PluginDuplexChannelOpenParams = { + hostRouteId: "route-1", + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "lease-1", + command: "paperclip-bridge", + }; + const reply: PluginDuplexChannelOpenResult = { workerSessionId: "ws-1" }; + expect(open.hostRouteId).toBe("route-1"); + expect(reply.workerSessionId).toBe("ws-1"); + }); + + it("rejects an open request that omits the host route identifier", () => { + // @ts-expect-error — hostRouteId is required. + const open: PluginDuplexChannelOpenParams = { + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "lease-1", + command: "paperclip-bridge", + }; + expect(open).toBeDefined(); + }); + + it("rejects an open request with a non-string command", () => { + const open: PluginDuplexChannelOpenParams = { + hostRouteId: "route-1", + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "lease-1", + // @ts-expect-error — command must be a string. + command: 42, + }; + expect(open).toBeDefined(); + }); + + it("accepts a valid write request", () => { + const write: PluginDuplexChannelWriteParams = { + workerSessionId: "ws-1", + data: "payload", + }; + expect(write.data).toBe("payload"); + }); + + it("rejects a write request that omits the data", () => { + // @ts-expect-error — data is required. + const write: PluginDuplexChannelWriteParams = { workerSessionId: "ws-1" }; + expect(write).toBeDefined(); + }); + + it("accepts a valid stop request", () => { + const stop: PluginDuplexChannelStopParams = { workerSessionId: "ws-1" }; + expect(stop.workerSessionId).toBe("ws-1"); + }); + + it("rejects a stop request that omits the worker session identifier", () => { + // @ts-expect-error — workerSessionId is required. + const stop: PluginDuplexChannelStopParams = {}; + expect(stop).toBeDefined(); + }); + + it("accepts a close request keyed only by the host route identifier", () => { + const close: PluginDuplexChannelCloseParams = { hostRouteId: "route-1" }; + const reply: PluginDuplexChannelCloseResult = { hostRouteId: "route-1" }; + expect(close.hostRouteId).toBe("route-1"); + expect(reply.hostRouteId).toBe("route-1"); + }); + + it("accepts a close request that also carries the worker session identifier", () => { + const close: PluginDuplexChannelCloseParams = { + hostRouteId: "route-1", + workerSessionId: "ws-1", + }; + expect(close.workerSessionId).toBe("ws-1"); + }); + + it("rejects a close request that omits the host route identifier", () => { + // @ts-expect-error — hostRouteId is the authoritative close key and is required. + const close: PluginDuplexChannelCloseParams = { workerSessionId: "ws-1" }; + expect(close).toBeDefined(); + }); +}); + +describe("duplex channel notification schemas", () => { + it("uses the generic notification method names", () => { + expect(DUPLEX_CHANNEL_DATA_NOTIFICATION).toBe("duplexChannel.data"); + expect(DUPLEX_CHANNEL_EXIT_NOTIFICATION).toBe("duplexChannel.exit"); + }); + + it("accepts a valid data notification", () => { + const data: PluginDuplexChannelDataParams = { + workerSessionId: "ws-1", + chunk: "output bytes", + }; + expect(data.chunk).toBe("output bytes"); + }); + + it("rejects a data notification that omits the chunk", () => { + // @ts-expect-error — chunk is required. + const data: PluginDuplexChannelDataParams = { workerSessionId: "ws-1" }; + expect(data).toBeDefined(); + }); + + it("accepts an exit notification with a numeric code and with null", () => { + const exit: PluginDuplexChannelExitParams = { + workerSessionId: "ws-1", + exitCode: 0, + }; + const exitNull: PluginDuplexChannelExitParams = { + workerSessionId: "ws-1", + exitCode: null, + }; + expect(exit.exitCode).toBe(0); + expect(exitNull.exitCode).toBeNull(); + }); + + it("rejects an exit notification with a non-numeric, non-null exit code", () => { + const exit: PluginDuplexChannelExitParams = { + workerSessionId: "ws-1", + // @ts-expect-error — exitCode must be a number or null. + exitCode: "0", + }; + expect(exit).toBeDefined(); + }); +}); diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 519770768d6..72585f73241 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -1082,6 +1082,102 @@ export const SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION = "setupTokenPty.output"; /** The worker→host notification method for one pseudo-terminal exit. */ export const SETUP_TOKEN_PTY_EXIT_NOTIFICATION = "setupTokenPty.exit"; +// --------------------------------------------------------------------------- +// Generic duplex channel worker methods. +// --------------------------------------------------------------------------- +// The host drives one persistent duplex channel inside a sandbox provider +// worker. The channel replaces the file transport of the sandbox callback bridge +// with one live bidirectional stream. These messages are generic. They model the +// setup-token pseudo-terminal contract above, but they carry no login command +// allowlist. The host owns the route. It mints an opaque host route identifier, +// carries that identifier in the open request, and keys the close on that +// identifier. The worker registers the channel under the host route identifier +// and returns a worker session identifier for the data and the exit notification +// binding only. The worker never keys a close on the worker session identifier, +// so the host closes a worker-created channel even when the open reply was lost +// and no worker session identifier arrived. The worker sends data and exit as +// notifications, never as a reply, so the host binds them by the worker session +// identifier while the route is open. + +/** The open request for one persistent duplex channel. The worker registers the channel by `hostRouteId`. */ +export interface PluginDuplexChannelOpenParams { + /** The host-owned opaque route identifier. The worker registers the channel by it. */ + hostRouteId: string; + /** The environment driver key, for the worker sandbox scope. */ + driverKey: string; + /** The company that owns the channel. */ + companyId: string; + /** The environment the channel runs in. */ + environmentId: string; + /** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */ + providerLeaseId: string; + /** The command the worker runs on the channel. */ + command: string; +} + +/** The open reply. It returns the worker session identifier for data binding only. */ +export interface PluginDuplexChannelOpenResult { + /** The worker session identifier. It binds the data and the exit notification only. */ + workerSessionId: string; +} + +/** The write request. It carries the worker session identifier and the raw input bytes. */ +export interface PluginDuplexChannelWriteParams { + /** The worker session identifier that the open reply returned. */ + workerSessionId: string; + /** The raw input bytes to write to the channel. */ + data: string; +} + +/** The stop request. It carries the worker session identifier. */ +export interface PluginDuplexChannelStopParams { + /** The worker session identifier that the open reply returned. */ + workerSessionId: string; +} + +/** The close request. The host route identifier is the authoritative key. */ +export interface PluginDuplexChannelCloseParams { + /** + * The host-owned opaque route identifier. This is the authoritative close key, + * so the host closes the channel even when no worker session identifier arrived + * after a lost open reply. + */ + hostRouteId: string; + /** + * A non-authoritative worker session identifier. The worker never keys the + * close on it. The field is optional, so a close with only the host route + * identifier is a valid request for this lifecycle. + */ + workerSessionId?: string; +} + +/** The close reply. It acknowledges the close and carries the same host route identifier. */ +export interface PluginDuplexChannelCloseResult { + /** The close acknowledgement. It carries the same host route identifier the close sent. */ + hostRouteId: string; +} + +/** The worker→host duplex channel data notification parameters. */ +export interface PluginDuplexChannelDataParams { + /** The worker session identifier that the open reply returned. */ + workerSessionId: string; + /** The raw channel output bytes. */ + chunk: string; +} + +/** The worker→host duplex channel exit notification parameters. */ +export interface PluginDuplexChannelExitParams { + /** The worker session identifier that the open reply returned. */ + workerSessionId: string; + /** The child exit code, or null when the child ended with no code. */ + exitCode: number | null; +} + +/** The worker→host notification method for one duplex channel data chunk. */ +export const DUPLEX_CHANNEL_DATA_NOTIFICATION = "duplexChannel.data"; +/** The worker→host notification method for one duplex channel exit. */ +export const DUPLEX_CHANNEL_EXIT_NOTIFICATION = "duplexChannel.exit"; + /** * Map of host→worker RPC method names to their `[params, result]` types. * @@ -1200,6 +1296,20 @@ export interface HostToWorkerMethods { params: PluginSetupTokenPtyCloseParams, result: PluginSetupTokenPtyCloseResult, ]; + /** Open one persistent duplex channel keyed by a host-owned route identifier. */ + duplexChannelOpen: [ + params: PluginDuplexChannelOpenParams, + result: PluginDuplexChannelOpenResult, + ]; + /** Write raw input to a persistent duplex channel, keyed by the worker session identifier. */ + duplexChannelWrite: [params: PluginDuplexChannelWriteParams, result: void]; + /** Stop a persistent duplex channel child, keyed by the worker session identifier. */ + duplexChannelStop: [params: PluginDuplexChannelStopParams, result: void]; + /** Close a persistent duplex channel by the host route identifier and return a bound acknowledgement. */ + duplexChannelClose: [ + params: PluginDuplexChannelCloseParams, + result: PluginDuplexChannelCloseResult, + ]; } /** Union of all host→worker method names. */ @@ -1245,6 +1355,10 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[] "setupTokenPtyInput", "setupTokenPtyStop", "setupTokenPtyClose", + "duplexChannelOpen", + "duplexChannelWrite", + "duplexChannelStop", + "duplexChannelClose", ] as const; // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index dc1b095d2ca..c7cadbc5e9b 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -104,6 +104,10 @@ import type { PluginSetupTokenPtyInputParams, PluginSetupTokenPtyStopParams, PluginSetupTokenPtyCloseParams, + PluginDuplexChannelOpenParams, + PluginDuplexChannelWriteParams, + PluginDuplexChannelStopParams, + PluginDuplexChannelCloseParams, PluginInvocationContext, WorkerToHostMethodName, WorkerToHostMethods, @@ -1624,6 +1628,18 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost case "setupTokenPtyClose": return handleSetupTokenPtyClose(params as PluginSetupTokenPtyCloseParams); + case "duplexChannelOpen": + return handleDuplexChannelOpen(params as PluginDuplexChannelOpenParams); + + case "duplexChannelWrite": + return handleDuplexChannelWrite(params as PluginDuplexChannelWriteParams); + + case "duplexChannelStop": + return handleDuplexChannelStop(params as PluginDuplexChannelStopParams); + + case "duplexChannelClose": + return handleDuplexChannelClose(params as PluginDuplexChannelCloseParams); + default: throw Object.assign( new Error(`Unknown method: ${method}`), @@ -1679,6 +1695,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost if (plugin.definition.onSetupTokenPtyInput) supportedMethods.push("setupTokenPtyInput"); if (plugin.definition.onSetupTokenPtyStop) supportedMethods.push("setupTokenPtyStop"); if (plugin.definition.onSetupTokenPtyClose) supportedMethods.push("setupTokenPtyClose"); + if (plugin.definition.onDuplexChannelOpen) supportedMethods.push("duplexChannelOpen"); + if (plugin.definition.onDuplexChannelWrite) supportedMethods.push("duplexChannelWrite"); + if (plugin.definition.onDuplexChannelStop) supportedMethods.push("duplexChannelStop"); + if (plugin.definition.onDuplexChannelClose) supportedMethods.push("duplexChannelClose"); return { ok: true, supportedMethods }; } @@ -2055,6 +2075,34 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return plugin.definition.onSetupTokenPtyClose(params); } + async function handleDuplexChannelOpen(params: PluginDuplexChannelOpenParams) { + if (!plugin.definition.onDuplexChannelOpen) { + throw methodNotImplemented("duplexChannelOpen"); + } + return plugin.definition.onDuplexChannelOpen(params); + } + + async function handleDuplexChannelWrite(params: PluginDuplexChannelWriteParams) { + if (!plugin.definition.onDuplexChannelWrite) { + throw methodNotImplemented("duplexChannelWrite"); + } + return plugin.definition.onDuplexChannelWrite(params); + } + + async function handleDuplexChannelStop(params: PluginDuplexChannelStopParams) { + if (!plugin.definition.onDuplexChannelStop) { + throw methodNotImplemented("duplexChannelStop"); + } + return plugin.definition.onDuplexChannelStop(params); + } + + async function handleDuplexChannelClose(params: PluginDuplexChannelCloseParams) { + if (!plugin.definition.onDuplexChannelClose) { + throw methodNotImplemented("duplexChannelClose"); + } + return plugin.definition.onDuplexChannelClose(params); + } + // ----------------------------------------------------------------------- // Event filter helper // ----------------------------------------------------------------------- diff --git a/packages/plugins/sdk/tests/worker-rpc-host.test.ts b/packages/plugins/sdk/tests/worker-rpc-host.test.ts index 92824962814..0b1f336c198 100644 --- a/packages/plugins/sdk/tests/worker-rpc-host.test.ts +++ b/packages/plugins/sdk/tests/worker-rpc-host.test.ts @@ -905,3 +905,191 @@ describe("worker setup-token pseudo-terminal dispatch", () => { } }); }); + +describe("worker duplex channel dispatch", () => { + it("dispatches open, write, stop, and close, and reports the duplex methods", async () => { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + let nextRequestId = 1; + + const writes: string[] = []; + let stopped = 0; + let closed = 0; + + // The plugin declares the four duplex channel handlers. The open returns a + // worker session id. The close keys on the host route id and returns a bound + // acknowledgement. + const controllablePlugin = definePlugin({ + async setup() {}, + async onDuplexChannelOpen(params) { + expect(params.hostRouteId).toBe("route-1"); + expect(params.command).toBe("paperclip-bridge"); + expect(params.providerLeaseId).toBe("lease-1"); + return { workerSessionId: "ws-1" }; + }, + async onDuplexChannelWrite(params) { + writes.push(params.data); + }, + async onDuplexChannelStop() { + stopped += 1; + }, + async onDuplexChannelClose(params) { + closed += 1; + return { hostRouteId: params.hostRouteId }; + }, + }); + + const worker = startWorkerRpcHost({ + plugin: controllablePlugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown) { + const id = `host-${nextRequestId++}`; + 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(createRequest(method, params, id))); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (isJsonRpcResponse(message)) { + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + } + }); + + try { + await expect( + callWorker("initialize", { + manifest: { + id: "paperclip.duplex-channel", + apiVersion: 1, + version: "1.0.0", + displayName: "Duplex Channel Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, + }, + config: {}, + databaseNamespace: null, + }), + ).resolves.toMatchObject({ + ok: true, + supportedMethods: expect.arrayContaining([ + "duplexChannelOpen", + "duplexChannelWrite", + "duplexChannelStop", + "duplexChannelClose", + ]), + }); + + await expect( + callWorker("duplexChannelOpen", { + hostRouteId: "route-1", + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "lease-1", + command: "paperclip-bridge", + }), + ).resolves.toEqual({ workerSessionId: "ws-1" }); + + await callWorker("duplexChannelWrite", { workerSessionId: "ws-1", data: "payload" }); + await callWorker("duplexChannelStop", { workerSessionId: "ws-1" }); + await expect( + callWorker("duplexChannelClose", { hostRouteId: "route-1" }), + ).resolves.toEqual({ hostRouteId: "route-1" }); + + expect(writes).toEqual(["payload"]); + expect(stopped).toBe(1); + expect(closed).toBe(1); + } finally { + worker.stop(); + hostReadline.close(); + } + }); + + it("reports no duplex methods when the plugin declares no duplex handlers", async () => { + const hostToWorker = new PassThrough(); + const workerToHost = new PassThrough(); + const hostReadline = createInterface({ input: workerToHost }); + const pending = new Map void>(); + let nextRequestId = 1; + + // A plugin with no duplex handlers advertises no duplex method. The Phase 1 + // capability `duplexCommandStream` still resolves false for this provider, + // because the prerequisite verb `duplexChannelOpen` is absent. + const barePlugin = definePlugin({ + async setup() {}, + }); + + const worker = startWorkerRpcHost({ + plugin: barePlugin, + stdin: hostToWorker, + stdout: workerToHost, + }); + + function callWorker(method: string, params: unknown) { + const id = `host-${nextRequestId++}`; + 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(createRequest(method, params, id))); + return result; + } + + hostReadline.on("line", (line) => { + const message = parseMessage(line); + if (isJsonRpcResponse(message)) { + pending.get(String(message.id))?.(message); + pending.delete(String(message.id)); + } + }); + + try { + const result = (await callWorker("initialize", { + manifest: { + id: "paperclip.bare", + apiVersion: 1, + version: "1.0.0", + displayName: "Bare Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: {}, + }, + config: {}, + databaseNamespace: null, + })) as { supportedMethods: string[] }; + + expect(result.supportedMethods).not.toContain("duplexChannelOpen"); + expect(result.supportedMethods).not.toContain("duplexChannelWrite"); + expect(result.supportedMethods).not.toContain("duplexChannelStop"); + expect(result.supportedMethods).not.toContain("duplexChannelClose"); + } finally { + worker.stop(); + hostReadline.close(); + } + }); +}); diff --git a/packages/plugins/sdk/vitest.config.ts b/packages/plugins/sdk/vitest.config.ts index faa6d98e10c..3834141eeeb 100644 --- a/packages/plugins/sdk/vitest.config.ts +++ b/packages/plugins/sdk/vitest.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["tests/**/*.test.ts"], + include: ["tests/**/*.test.ts", "src/**/*.test.ts"], }, }); diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 5a599fa82a1..30ff21d289f 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -173,6 +173,17 @@ export interface SandboxProviderCapabilities { * `false`. */ concurrentSyncOperations?: boolean; + /** + * Provider opens one persistent, bidirectional duplex channel that carries the + * command stream, in place of the file transport of the callback bridge. This + * is an opt-in behavioral guarantee, not a worker-method property: a provider + * that keeps persistent sessions and runs independent control commands still + * does not carry a framed duplex stream unless it declares this key. An omitted + * key denies the capability, so the provider keeps the file bridge. Only a + * provider that declares this key `true` and whose worker verifies the duplex + * open method selects the duplex channel path. + */ + duplexCommandStream?: boolean; } export interface PluginEnvironmentDriverDeclaration { diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index b710c86c022..37b959efefa 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -167,6 +167,7 @@ export const sandboxProviderCapabilitiesSchema = z.object({ independentControlCommands: z.boolean().optional(), incrementalSessionOutput: z.boolean().optional(), concurrentSyncOperations: z.boolean().optional(), + duplexCommandStream: z.boolean().optional(), }).strict(); export type SandboxProviderCapabilitiesInput = z.infer; diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index af1febfcaa2..42d8420c7ff 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -22,6 +22,7 @@ const SNAPSHOT: EffectiveSandboxCapabilities = { // Concurrent sync operations need BOTH sync verbs; this snapshot verified only // inbound sync, so the opt-in stays off. concurrentSyncOperations: false, + duplexCommandStream: false, }; // A snapshot that grants every capability. A test overrides one flag to prove @@ -34,6 +35,7 @@ const FULL_GRANT: EffectiveSandboxCapabilities = { independentControlCommands: true, incrementalSessionOutput: true, concurrentSyncOperations: true, + duplexCommandStream: true, }; // Build a sandbox execution target with a fixed snapshot and a fixed diff --git a/server/src/__tests__/environment-execution-target-duplex.test.ts b/server/src/__tests__/environment-execution-target-duplex.test.ts new file mode 100644 index 00000000000..7af64eb431f --- /dev/null +++ b/server/src/__tests__/environment-execution-target-duplex.test.ts @@ -0,0 +1,379 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockResolveEnvironmentDriverConfigForRuntime, mockResolvePluginSandboxProviderDriverById } = + vi.hoisted(() => ({ + mockResolveEnvironmentDriverConfigForRuntime: vi.fn(), + mockResolvePluginSandboxProviderDriverById: vi.fn(), + })); + +vi.mock("../services/environment-config.js", () => ({ + resolveEnvironmentDriverConfigForRuntime: mockResolveEnvironmentDriverConfigForRuntime, +})); + +// The centralized duplex authorization gate resolves the exact lease capability +// snapshot before it calls the driver. The plugin branch of the snapshot reads +// the pinned plugin's declaration from the database. These service tests carry no +// database, so replace only the by-id declaration resolver. The test sets the +// resolved declaration per case; every other export stays real. +vi.mock("../services/plugin-environment-driver.js", async (importActual) => ({ + ...(await importActual()), + resolvePluginSandboxProviderDriverById: mockResolvePluginSandboxProviderDriverById, +})); + +import type { EffectiveSandboxCapabilities } from "@paperclipai/adapter-utils/execution-target"; +import { createSshCommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/ssh"; +import type { Environment, EnvironmentLease } from "@paperclipai/shared"; +import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js"; +import { + buildSandboxCapabilityNarrowing, + environmentRuntimeService, +} from "../services/environment-runtime.js"; +import type { + EnvironmentRuntimeDriver, + EnvironmentRuntimeService, +} from "../services/environment-runtime.js"; +import type { + DuplexChannelHostSession, + PluginWorkerManager, +} from "../services/plugin-worker-manager.js"; + +// A snapshot that grants the opt-in duplex capability, plus the rest true so the +// gate reads only the duplex flag. +const DUPLEX_GRANT: EffectiveSandboxCapabilities = { + reusableLeases: true, + nativeSyncIn: true, + nativeSyncOut: true, + persistentProcessSessions: true, + independentControlCommands: true, + incrementalSessionOutput: true, + duplexCommandStream: true, +}; + +const DUPLEX_ABSENT: EffectiveSandboxCapabilities = { + ...DUPLEX_GRANT, + duplexCommandStream: false, +}; + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +// A fake worker manager whose one worker returns a controllable host session. +// The session records every host→worker call and lets the test settle the exit. +function makeFakeWorkerManager() { + let settleWait: (value: { exitCode: number | null }) => void = () => {}; + const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => { + settleWait = resolve; + }); + const hostSession = { + onData: vi.fn(), + write: vi.fn(), + wait: vi.fn(() => waitPromise), + kill: vi.fn(), + close: vi.fn(async () => {}), + } satisfies DuplexChannelHostSession; + const openDuplexChannel = vi.fn(async () => hostSession); + const worker = { openDuplexChannel, supportedMethods: ["duplexChannelOpen"] }; + const manager = { + getWorker: vi.fn(() => worker), + } as unknown as PluginWorkerManager; + return { manager, worker, hostSession, openDuplexChannel, settleWait }; +} + +const PLUGIN_LEASE: EnvironmentLease = { + id: "lease-1", + companyId: "company-1", + providerLeaseId: "provider-lease-1", + metadata: { + sandboxProviderPlugin: true, + pluginId: "test.plugin", + provider: "daytona", + }, +} as unknown as EnvironmentLease; + +const SANDBOX_ENVIRONMENT: Environment = { + id: "env-1", + driver: "sandbox", +} as unknown as Environment; + +// A plugin driver declaration that grants the opt-in duplex capability. The +// centralized gate reads it through the by-id resolver and pairs it with the +// worker's verified `duplexChannelOpen` verb to grant the capability. +const DUPLEX_DECLARED_DRIVER = { + plugin: {}, + driver: { sandboxCapabilities: { duplexCommandStream: true } }, +}; + +// The fixed refusal the centralized gate throws when the lease does not grant the +// opt-in duplex capability. +const DUPLEX_DENIED = /does not grant the duplex command stream capability/; + +beforeEach(() => { + // The default resolves a clean sandbox config and a declaration that grants the + // duplex capability, so the plugin lease reaches the worker. Each refusal test + // overrides one input to deny the capability. + mockResolveEnvironmentDriverConfigForRuntime.mockReset(); + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "sandbox", + config: { provider: "daytona", timeoutMs: 30_000 }, + }); + mockResolvePluginSandboxProviderDriverById.mockReset(); + mockResolvePluginSandboxProviderDriverById.mockResolvedValue(DUPLEX_DECLARED_DRIVER); +}); + +describe("sandbox driver duplex channel wiring", () => { + it("reaches the worker manager route with the lease scope and delegates the channel members", async () => { + const { manager, worker, hostSession, settleWait } = makeFakeWorkerManager(); + const service = environmentRuntimeService({} as never, { pluginWorkerManager: manager }); + + const channel = await service.openDuplexChannel({ + environment: SANDBOX_ENVIRONMENT, + lease: PLUGIN_LEASE, + command: "bridge-callback", + }); + + // The driver resolves the worker by the pinned plugin id and passes the same + // lease scope the sandbox execute path uses. + expect(manager.getWorker).toHaveBeenCalledWith("test.plugin"); + expect(worker.openDuplexChannel).toHaveBeenCalledWith({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: "provider-lease-1", + command: "bridge-callback", + }); + + // write / stop / close map to write / kill / close on the host session. + channel.write("input-bytes"); + expect(hostSession.write).toHaveBeenCalledWith("input-bytes"); + channel.stop(); + expect(hostSession.kill).toHaveBeenCalledTimes(1); + await channel.close(); + expect(hostSession.close).toHaveBeenCalledTimes(1); + + // onData maps one to one. + const dataListener = vi.fn(); + channel.onData(dataListener); + expect(hostSession.onData).toHaveBeenCalledWith(dataListener); + + // onExit bridges the host session's one-time wait() to the exit listener. + const exitListener = vi.fn(); + channel.onExit(exitListener); + expect(exitListener).not.toHaveBeenCalled(); + settleWait({ exitCode: 7 }); + await flushMicrotasks(); + expect(exitListener).toHaveBeenCalledWith({ exitCode: 7 }); + }); + + it("refuses a lease that is not a plugin-backed sandbox lease before it reaches the worker", async () => { + const { manager, worker } = makeFakeWorkerManager(); + const service = environmentRuntimeService({} as never, { pluginWorkerManager: manager }); + const nonPluginLease = { + id: "lease-2", + companyId: "company-1", + providerLeaseId: "provider-lease-2", + metadata: { pluginId: "test.plugin", provider: "daytona" }, + } as unknown as EnvironmentLease; + + // The lease is not a plugin-backed sandbox lease, so the effective snapshot + // never grants the opt-in duplex capability. The centralized gate refuses the + // open before the driver, so the worker `duplexChannelOpen` RPC never runs. + await expect( + service.openDuplexChannel({ + environment: SANDBOX_ENVIRONMENT, + lease: nonPluginLease, + command: "bridge-callback", + }), + ).rejects.toThrow(DUPLEX_DENIED); + expect(worker.openDuplexChannel).not.toHaveBeenCalled(); + }); +}); + +// Direct regressions for the centralized authorization gate on +// EnvironmentRuntimeService.openDuplexChannel. The gate resolves the exact lease +// capability snapshot and refuses unless it grants the opt-in duplex capability. +// Every refusal must happen before the driver, so the worker `duplexChannelOpen` +// RPC never runs. +describe("EnvironmentRuntimeService.openDuplexChannel capability gate", () => { + it("refuses a lease whose provider does not declare the duplex capability", async () => { + const { manager, worker } = makeFakeWorkerManager(); + // The worker verifies the duplex verb, but the declaration omits the opt-in + // capability. An opt-in capability needs an explicit declaration, so the + // effective snapshot denies it. + mockResolvePluginSandboxProviderDriverById.mockResolvedValue({ plugin: {}, driver: {} }); + const service = environmentRuntimeService({} as never, { pluginWorkerManager: manager }); + + await expect( + service.openDuplexChannel({ + environment: SANDBOX_ENVIRONMENT, + lease: PLUGIN_LEASE, + command: "bridge-callback", + }), + ).rejects.toThrow(DUPLEX_DENIED); + expect(worker.openDuplexChannel).not.toHaveBeenCalled(); + }); + + it("refuses a lease whose worker does not verify the duplex verb", async () => { + const { manager, worker } = makeFakeWorkerManager(); + // The declaration grants the capability, but the worker does not advertise + // the `duplexChannelOpen` verb, so the runtime never verified it. A + // declaration never grants an unverified capability. + worker.supportedMethods = []; + const service = environmentRuntimeService({} as never, { pluginWorkerManager: manager }); + + await expect( + service.openDuplexChannel({ + environment: SANDBOX_ENVIRONMENT, + lease: PLUGIN_LEASE, + command: "bridge-callback", + }), + ).rejects.toThrow(DUPLEX_DENIED); + expect(worker.openDuplexChannel).not.toHaveBeenCalled(); + }); + + it("refuses a lease narrowed away from the duplex capability", async () => { + // A fake driver returns a snapshot that grants every capability except the + // duplex one, which models a lease narrowed away from it. The gate keys on + // the exact duplex flag, so it refuses even though the other capabilities are + // granted, and never calls the driver open. + const openDuplexChannel = vi.fn(); + const narrowedDriver = { + driver: "sandbox", + acquireRunLease: vi.fn(), + releaseRunLease: vi.fn(), + effectiveSandboxCapabilities: vi.fn(async () => ({ ...DUPLEX_ABSENT })), + openDuplexChannel, + } as unknown as EnvironmentRuntimeDriver; + const service = environmentRuntimeService({} as never, { drivers: [narrowedDriver] }); + + await expect( + service.openDuplexChannel({ + environment: SANDBOX_ENVIRONMENT, + lease: { ...PLUGIN_LEASE, metadata: { ...PLUGIN_LEASE.metadata, driver: "sandbox" } }, + command: "bridge-callback", + }), + ).rejects.toThrow(DUPLEX_DENIED); + expect(openDuplexChannel).not.toHaveBeenCalled(); + }); + + it("refuses a lease whose provider config fails to resolve", async () => { + const { manager, worker } = makeFakeWorkerManager(); + // The declaration grants the capability and the worker verifies the verb, but + // the provider config cannot be resolved. An untrusted provider fails closed, + // so the config-resolution failure narrows the duplex capability away. + mockResolveEnvironmentDriverConfigForRuntime.mockRejectedValue(new Error("config unresolved")); + const service = environmentRuntimeService({} as never, { pluginWorkerManager: manager }); + + await expect( + service.openDuplexChannel({ + environment: SANDBOX_ENVIRONMENT, + lease: PLUGIN_LEASE, + command: "bridge-callback", + }), + ).rejects.toThrow(DUPLEX_DENIED); + expect(worker.openDuplexChannel).not.toHaveBeenCalled(); + }); + + it("narrows the duplex capability away when the provider config resolution fails", () => { + // Unit cover for the narrowing rule the config-failure case relies on: a + // failed config resolution fails closed on the opt-in duplex capability. + const narrowing = buildSandboxCapabilityNarrowing({ + leasePolicy: "reuse_by_environment", + leaseMetadata: {}, + configResolutionFailed: true, + }); + expect(narrowing.duplexCommandStream).toBe(false); + + const granted = buildSandboxCapabilityNarrowing({ + leasePolicy: "reuse_by_environment", + leaseMetadata: {}, + configResolutionFailed: false, + }); + expect(granted.duplexCommandStream).toBeUndefined(); + }); +}); + +// Build a sandbox execution target with a fixed capability snapshot and a fake +// environment runtime whose openDuplexChannel is a spy. The helper returns the +// runner and the spy so a test reads the capability-gated member. +async function buildSandboxRunner(input: { + snapshot: EffectiveSandboxCapabilities | null; +}) { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "sandbox", + config: { provider: "daytona", timeoutMs: 30_000 }, + }); + + const openDuplexChannel = vi.fn(async () => ({ + write: vi.fn(), + onData: vi.fn(), + onExit: vi.fn(), + stop: vi.fn(), + close: vi.fn(async () => {}), + })); + const environmentRuntime = { + execute: vi.fn().mockResolvedValue({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "ok", + stderr: "", + metadata: {}, + }), + supportsSync: () => false, + syncIn: vi.fn(), + syncOut: vi.fn(), + openDuplexChannel, + effectiveSandboxCapabilities: vi.fn(async () => + input.snapshot ? Object.freeze({ ...input.snapshot }) : null, + ), + } as unknown as EnvironmentRuntimeService; + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "codex_local", + environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } }, + leaseId: "lease-1", + leaseMetadata: { remoteCwd: "/work" }, + lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never, + environmentRuntime, + }); + if (!target || target.kind !== "remote" || target.transport !== "sandbox") { + throw new Error("expected a sandbox execution target"); + } + return { runner: target.runner, openDuplexChannel }; +} + +describe("inline sandbox runner duplex capability gate", () => { + it("exposes openDuplexChannel that delegates to the runtime when the capability is granted", async () => { + const { runner, openDuplexChannel } = await buildSandboxRunner({ snapshot: DUPLEX_GRANT }); + expect(runner?.openDuplexChannel).toBeDefined(); + + await runner!.openDuplexChannel!({ command: "bridge-callback" }); + expect(openDuplexChannel).toHaveBeenCalledWith({ + environment: expect.objectContaining({ id: "env-1" }), + lease: expect.objectContaining({ id: "lease-1" }), + command: "bridge-callback", + }); + }); + + it("omits openDuplexChannel when the capability is absent", async () => { + const { runner } = await buildSandboxRunner({ snapshot: DUPLEX_ABSENT }); + expect(runner?.openDuplexChannel).toBeUndefined(); + }); + + it("omits openDuplexChannel when the capability snapshot is null", async () => { + const { runner } = await buildSandboxRunner({ snapshot: null }); + expect(runner?.openDuplexChannel).toBeUndefined(); + }); +}); + +describe("ssh runner factory", () => { + it("omits openDuplexChannel", () => { + const runner = createSshCommandManagedRuntimeRunner({ + spec: { remoteCwd: "/work" } as never, + }); + expect(runner.openDuplexChannel).toBeUndefined(); + }); +}); diff --git a/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs b/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs new file mode 100644 index 00000000000..2cd1967d957 --- /dev/null +++ b/server/src/__tests__/fixtures/plugin-worker-duplex-channel.cjs @@ -0,0 +1,174 @@ +// Test worker fixture for the host-owned duplex channel route state machine. The +// fixture drives the manager route state machine through the four typed methods +// (open, write, stop, close) and the data and exit notifications. +// +// The duplex channel is generic. It carries no command allowlist. The test +// encodes a JSON directive in the forwarded `providerLeaseId`, so one fixture +// serves every route case: +// - `mode`: "normal" | "malformed-open" | "no-open-reply" | "duplicate-open-reply" | +// "no-write-reply" +// - `workerSessionId`: the worker session id the open reply returns (default "ws-1") +// - `data`: an array of `{ chunk, sid? }`. The fixture emits each as a data +// notification after the open reply. `sid` defaults to the real worker session +// id; a test sets a wrong `sid` to prove the host drops a mismatched +// notification and counts a protocol error. +// - `exitCode`: when set, the fixture emits an exit notification after the data. +// - `echoInput`: when true, the fixture echoes each `duplexChannelWrite` back as +// one data notification for the bound session. +// - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the +// close reply, so a test proves the host retires the worker on an unconfirmed +// close. +const readline = require("node:readline"); + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`); +} + +// The registered channels, keyed by the host route id. Each entry records the +// bound worker session id and the close directive. +const routes = new Map(); + +function parseDirective(raw) { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +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; + const params = message.params ?? {}; + + if (method === "initialize") { + send({ + jsonrpc: "2.0", + id: message.id, + result: { + ok: true, + supportedMethods: [ + "duplexChannelOpen", + "duplexChannelWrite", + "duplexChannelStop", + "duplexChannelClose", + ], + }, + }); + return; + } + + if (method === "duplexChannelOpen") { + const directive = parseDirective(params.providerLeaseId); + const mode = directive.mode ?? "normal"; + const workerSessionId = directive.workerSessionId ?? "ws-1"; + const closeMode = directive.closeMode ?? "ack"; + routes.set(params.hostRouteId, { + workerSessionId, + closeMode, + echoInput: directive.echoInput === true, + noWriteReply: mode === "no-write-reply", + }); + + if (mode === "no-open-reply") { + // Never reply, so the host open call times out. + return; + } + if (mode === "malformed-open") { + // Reply with no worker session id, so the host terminalizes the route. + send({ jsonrpc: "2.0", id: message.id, result: {} }); + return; + } + + const reply = () => + send({ jsonrpc: "2.0", id: message.id, result: { workerSessionId } }); + reply(); + if (mode === "duplicate-open-reply") { + // Send a second open reply for the same request id. The host drops it. + reply(); + } + + // Emit the scripted data and the exit after the open reply, so the host + // binds the route first. + setImmediate(() => { + const data = Array.isArray(directive.data) ? directive.data : []; + for (const entry of data) { + send({ + jsonrpc: "2.0", + method: "duplexChannel.data", + params: { + workerSessionId: entry.sid ?? workerSessionId, + chunk: entry.chunk, + }, + }); + } + if (typeof directive.exitCode === "number") { + send({ + jsonrpc: "2.0", + method: "duplexChannel.exit", + params: { workerSessionId, exitCode: directive.exitCode }, + }); + } + }); + return; + } + + if (method === "duplexChannelWrite") { + const entry = [...routes.values()].find( + (route) => route.workerSessionId === params.workerSessionId, + ); + if (entry && entry.noWriteReply) { + // Never reply, so the host write call stays pending. The test proves the + // host ends the route on the pending-request bound. + return; + } + if (entry && entry.echoInput) { + // Echo the input back as one data notification for the bound session, so a + // test proves the input reaches the worker and the output routes back. + send({ + jsonrpc: "2.0", + method: "duplexChannel.data", + params: { workerSessionId: entry.workerSessionId, chunk: `echo:${params.data}` }, + }); + } + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + + if (method === "duplexChannelStop") { + send({ jsonrpc: "2.0", id: message.id, result: null }); + return; + } + + if (method === "duplexChannelClose") { + const entry = routes.get(params.hostRouteId); + routes.delete(params.hostRouteId); + const closeMode = entry ? entry.closeMode : "ack"; + if (closeMode === "no-ack") { + // Never reply, so the host close call times out and the host retires us. + return; + } + if (closeMode === "bad-ack") { + send({ jsonrpc: "2.0", id: message.id, result: { hostRouteId: "mismatched-route" } }); + return; + } + send({ jsonrpc: "2.0", id: message.id, result: { hostRouteId: params.hostRouteId } }); + 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-duplex.test.ts b/server/src/__tests__/plugin-worker-manager-duplex.test.ts new file mode 100644 index 00000000000..7a02b92b50a --- /dev/null +++ b/server/src/__tests__/plugin-worker-manager-duplex.test.ts @@ -0,0 +1,505 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { createPluginWorkerHandle } from "../services/plugin-worker-manager.js"; + +const FIXTURES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures"); +const DUPLEX_CHANNEL_WORKER_ENTRYPOINT = path.join( + FIXTURES_DIR, + "plugin-worker-duplex-channel.cjs", +); + +const TEST_MANIFEST: PaperclipPluginManifestV1 = { + id: "test.plugin", + apiVersion: 1, + version: "1.0.0", + displayName: "Test plugin", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: [], + entrypoints: { worker: "dist/worker.js" }, +}; + +function makeDuplexHandle(extra?: Record) { + return createPluginWorkerHandle("test.plugin", { + entrypointPath: DUPLEX_CHANNEL_WORKER_ENTRYPOINT, + manifest: TEST_MANIFEST, + config: {}, + instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" }, + apiVersion: 1, + hostHandlers: {}, + ...extra, + }); +} + +// The test directive rides in `providerLeaseId`, an opaque field the manager +// forwards to the worker unchanged. The duplex channel is generic, so the +// command is a plain fixed string with no allowlist. +function duplexOpenInput(directive: unknown) { + return { + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + providerLeaseId: JSON.stringify(directive), + command: "bridge-callback", + }; +} + +describe("plugin worker manager duplex channel route", () => { + it("delivers data only for the exact bound worker session id and drops a mismatch", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + data: [ + { chunk: "good-1" }, + { chunk: "forged", sid: "ws-EVIL" }, + { chunk: "good-2" }, + ], + exitCode: 0, + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + // The forged notification carries a wrong worker session id, so the host + // drops it. Only the two bound chunks reach the listener, in order. + expect(chunks).toEqual(["good-1", "good-2"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("routes input to the worker and back to the listener", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ workerSessionId: "ws-A", echoInput: true }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + session.write("callback-payload"); + // The worker echoes the input as one data notification for the bound + // session, so the listener receives it. + await vi.waitFor(() => expect(chunks).toContain("echo:callback-payload")); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("buffers early data in order until a listener attaches and drains it in order", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + data: [{ chunk: "one" }, { chunk: "two" }, { chunk: "three" }], + }), + ); + // Wait so the three data notifications arrive and buffer before a listener + // attaches. The drain then delivers them in order. + await new Promise((resolve) => setTimeout(resolve, 60)); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await vi.waitFor(() => expect(chunks.length).toBe(3)); + expect(chunks).toEqual(["one", "two", "three"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("isolates a throwing listener during live delivery so later chunks still route", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + data: [{ chunk: "ok-1" }, { chunk: "boom" }, { chunk: "ok-2" }], + exitCode: 0, + }), + ); + const chunks: string[] = []; + // The listener throws on one chunk. The manager catches the throw, so it + // does not escape the worker stdout notification handler. The later chunk + // still routes and the route still settles. + session.onData((chunk) => { + chunks.push(chunk); + if (chunk === "boom") throw new Error("listener failure"); + }); + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(chunks).toEqual(["ok-1", "boom", "ok-2"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("isolates a throwing listener during the buffered replay so every buffered chunk routes", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + data: [{ chunk: "one" }, { chunk: "boom" }, { chunk: "three" }], + }), + ); + // Wait so the three data notifications arrive and buffer before a listener + // attaches. The drain then delivers them in order. + await new Promise((resolve) => setTimeout(resolve, 60)); + const chunks: string[] = []; + // The listener throws on one buffered chunk. The manager catches the throw + // inside the drain, so it does not escape `onData` and every buffered chunk + // still routes. + expect(() => + session.onData((chunk) => { + chunks.push(chunk); + if (chunk === "boom") throw new Error("listener failure"); + }), + ).not.toThrow(); + expect(chunks).toEqual(["one", "boom", "three"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("binds the worker session id one time and ignores a duplicate open reply", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + mode: "duplicate-open-reply", + workerSessionId: "ws-A", + data: [{ chunk: "hello" }], + exitCode: 0, + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + // The duplicate open reply never rebinds or reopens the route, so the + // session runs normally on the one bind. + await expect(session.wait()).resolves.toEqual({ exitCode: 0 }); + expect(chunks).toEqual(["hello"]); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("terminalizes and fails closed on a malformed open reply, then admits a later open", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + await expect( + handle.openDuplexChannel(duplexOpenInput({ mode: "malformed-open" })), + ).rejects.toThrow("DUPLEX_CHANNEL_OPEN_FAILED"); + // The terminalize closed the route by the host route id and the worker + // acknowledged the close, so a later open is admitted. + const session = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })); + expect(session).toBeDefined(); + await session.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("permits one active duplex channel per worker", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const first = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })); + // A second open while the first route is not closed rejects with one fixed + // non-secret error before it reaches the worker. + await expect( + handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })), + ).rejects.toThrow("DUPLEX_CHANNEL_ROUTE_BUSY"); + await first.close(); + // After the first route closes and the worker acknowledges the close, a new + // open is admitted. + const second = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })); + await second.close(); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + // ------------------------------------------------------------------------- + // The five explicit bounds. Each bound ends the route when it is exceeded. + // ------------------------------------------------------------------------- + + it("ends the route when the pre-bind buffered bytes pass the bound", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxPreBindBufferedChars: 10 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + data: [ + { chunk: "aaaaa" }, // total 5 → buffered + { chunk: "bbbbb" }, // total 10 → buffered + { chunk: "ccccc" }, // total 15 > 10 → end route + ], + }), + ); + // No listener attaches, so the data buffers. The cumulative bytes pass the + // bound and the route ends. The login wait resolves with a null exit code. + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the route when the pre-bind buffered frame count passes the bound", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxPreBindBufferedFrames: 2 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + data: [{ chunk: "a" }, { chunk: "b" }, { chunk: "c" }], + }), + ); + // No listener attaches, so the data buffers. The third frame passes the + // frame-count bound and the route ends. + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the route when the pending request count passes the bound", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxPendingRequests: 2 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ mode: "no-write-reply", workerSessionId: "ws-A" }), + ); + const waitResult = session.wait(); + // The worker never replies to a write, so each write stays pending. The + // third write passes the pending-request bound and the route ends. + session.write("one"); + session.write("two"); + session.write("three"); + await expect(waitResult).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the route when one host-to-worker write passes the size bound", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxWriteChars: 8 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ workerSessionId: "ws-A" }), + ); + const waitResult = session.wait(); + // One write is larger than the size bound, so the host rejects it and ends + // the route before it reaches the worker. + session.write("this-write-is-too-large"); + await expect(waitResult).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the route when the protocol error count passes the bound", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxProtocolErrors: 2 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + data: [ + { chunk: "e1", sid: "ws-EVIL" }, + { chunk: "e2", sid: "ws-EVIL" }, + { chunk: "e3", sid: "ws-EVIL" }, + ], + }), + ); + // Each mismatched-session data frame is a protocol error. The third frame + // passes the error bound and the route ends. + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the route when the total data bytes pass the cap for a bound listener", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxTotalDataBytes: 10 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + data: [ + { chunk: "aaaaa" }, // total 5 → deliver + { chunk: "bbbbb" }, // total 10 → deliver + { chunk: "ccccc" }, // total 15 > 10 → end route + ], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + // A listener is bound, so the host forwards each chunk until the cumulative + // bytes pass the cap. The third chunk passes the cap, so the host drops it + // and ends the route. The listener never receives data past the cap. + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + expect(chunks).toEqual(["aaaaa", "bbbbb"]); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("counts inbound bytes, not characters, against the total cap", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxTotalDataBytes: 4 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + // "€" is one character but three bytes in UTF-8. The first chunk is 3 + // bytes (≤ 4), so the host delivers it. The second chunk brings the + // total to 6 bytes (> 4), so the host ends the route. A character count + // would admit both chunks (2 ≤ 4), so one delivered chunk proves the + // host counts bytes. + data: [{ chunk: "€" }, { chunk: "€" }], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + expect(chunks).toEqual(["€"]); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the active route when the lifetime timer expires", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxDurationMs: 100 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ mode: "normal", workerSessionId: "ws-A" }), + ); + const waitResult = session.wait(); + // The route sends no exit. The lifetime timer expires, so the host ends the + // route and resolves the wait with the fixed null exit code. + await expect(waitResult).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the route at once when one inbound chunk passes the per-chunk limit before a listener binds", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxChunkChars: 4 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + data: [{ chunk: "this-one-chunk-is-too-large" }], + }), + ); + // No listener attaches. One inbound chunk is larger than the per-chunk + // limit, so the host ends the route at once. The default protocol-error + // budget is far above one, so a single chunk that ends the route proves the + // host does not treat it as a protocol error. + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("ends the route at once when one inbound chunk passes the per-chunk limit after a listener binds", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { maxChunkChars: 4 }, + }); + try { + await handle.start(); + const session = await handle.openDuplexChannel( + duplexOpenInput({ + workerSessionId: "ws-A", + data: [{ chunk: "this-one-chunk-is-too-large" }], + }), + ); + const chunks: string[] = []; + session.onData((chunk) => chunks.push(chunk)); + // A listener is bound. One inbound chunk is larger than the per-chunk + // limit, so the host ends the route at once and never forwards the chunk. + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + expect(chunks).toEqual([]); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + // ------------------------------------------------------------------------- + // Authoritative closure and worker retirement. + // ------------------------------------------------------------------------- + + it("closes the route with a fixed exit when the worker exits", async () => { + const handle = makeDuplexHandle(); + try { + await handle.start(); + const session = await handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })); + const waitResult = session.wait(); + await handle.stop(); + // A worker exit closes the one route and resolves the wait with the fixed + // non-secret exit. + await expect(waitResult).resolves.toEqual({ exitCode: null }); + } finally { + await handle.stop().catch(() => undefined); + } + }); + + it("retires the worker on an unconfirmed close acknowledgement", async () => { + const handle = makeDuplexHandle({ + duplexChannelLimits: { closeTimeoutMs: 200 }, + }); + try { + await handle.start(); + const exited = new Promise((resolve) => { + handle.on("exit", () => resolve()); + }); + const session = await handle.openDuplexChannel( + duplexOpenInput({ mode: "normal", closeMode: "bad-ack" }), + ); + await session.close(); + // The close acknowledgement carried a mismatched host route id, so the host + // fails closed and retires the worker before any reuse. + await exited; + await expect( + handle.openDuplexChannel(duplexOpenInput({ mode: "normal" })), + ).rejects.toThrow(); + } finally { + await handle.stop().catch(() => undefined); + } + }); +}); diff --git a/server/src/__tests__/sandbox-capability-contract.test.ts b/server/src/__tests__/sandbox-capability-contract.test.ts index 571a8ddbbc8..1c9c9336205 100644 --- a/server/src/__tests__/sandbox-capability-contract.test.ts +++ b/server/src/__tests__/sandbox-capability-contract.test.ts @@ -324,6 +324,48 @@ describe("sandbox capability contract normalizer", () => { expect(outOnly.concurrentSyncOperations).toBe(false); }); + it("test_duplex_command_stream_absent_declaration_resolves_false", () => { + // The duplex channel is opt-in and fail-closed. An absent declaration denies + // the capability even when the worker verifies the duplex open verb. This + // matches the incremental-session-output pattern: an opt-in behavioral + // guarantee needs a positive declaration, not just a verified verb. + const undeclared = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["duplexChannelOpen"], + declared: null, + }); + expect(undeclared.duplexCommandStream).toBe(false); + }); + + it("test_duplex_command_stream_needs_verified_worker_method", () => { + // A declaration never grants the capability without the verified duplex open + // verb. A provider that declares the capability but whose worker does not + // report the duplex open method resolves false. + const declaredButUnverified = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["environmentExecute"], + declared: { duplexCommandStream: true }, + }); + expect(declaredButUnverified.duplexCommandStream).toBe(false); + }); + + it("test_duplex_command_stream_declared_and_verified_resolves_true_but_narrowing_removes_it", () => { + // A provider that declares the capability and whose worker verifies the + // duplex open verb gets the capability. + const granted = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["duplexChannelOpen"], + declared: { duplexCommandStream: true }, + }); + expect(granted.duplexCommandStream).toBe(true); + + // Per-target narrowing still removes a verified and declared capability, so a + // lease that cannot use the duplex channel keeps the file bridge. + const narrowed = resolveEffectiveSandboxCapabilities({ + verifiedMethods: ["duplexChannelOpen"], + declared: { duplexCommandStream: true }, + narrowing: { duplexCommandStream: false }, + }); + expect(narrowed.duplexCommandStream).toBe(false); + }); + it("test_unknown_or_unavailable_verification_resolves_false", () => { const declaredAll = { reusableLeases: true, diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 75738c5943a..fccf326a4c0 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -505,6 +505,21 @@ export async function resolveEnvironmentExecutionTarget(input: { }), } : {}), + // Expose the duplex channel only when the effective snapshot grants + // the opt-in `duplexCommandStream` capability. A null snapshot + // (resolution failed or the snapshot is not resolvable) leaves the + // member undefined, so the caller keeps the file bridge. This mirrors + // the syncIn/syncOut gate above and fails closed. + ...(effectiveCapabilities?.duplexCommandStream + ? { + openDuplexChannel: (channelInput) => + input.environmentRuntime!.openDuplexChannel({ + environment: input.environment as Environment, + lease: input.lease!, + command: channelInput.command, + }), + } + : {}), } : undefined, }; diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index db554c9dbd7..9588740e032 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -14,6 +14,9 @@ import type { } from "@paperclipai/shared"; import { resolveDeclaredSandboxCapabilities } from "@paperclipai/shared"; import type { EffectiveSandboxCapabilities } from "@paperclipai/adapter-utils/execution-target"; +import type { + CommandManagedDuplexChannel, +} from "@paperclipai/adapter-utils/command-managed-runtime"; import type { PluginEnvironmentAcquireLeaseParams, PluginEnvironmentExecuteResult, @@ -52,7 +55,12 @@ import { sandboxConfigFromLeaseMetadataLoose, } from "./sandbox-provider-runtime.js"; import { pluginRegistryService } from "./plugin-registry.js"; -import type { ExecuteLogSink, PluginWorkerManager } from "./plugin-worker-manager.js"; +import type { + ExecuteLogSink, + PluginWorkerManager, + DuplexChannelHostSession, + DuplexChannelOpenInput as WorkerManagerDuplexChannelOpenInput, +} from "./plugin-worker-manager.js"; import { REUSABLE_LEASE_WORKER_METHODS, destroyPluginEnvironmentLease, @@ -78,6 +86,12 @@ import { logger } from "../middleware/logger.js"; // this constant and the plain provider identifiers only. const SANDBOX_ORPHAN_CLEANUP_WRITE_ERROR_KIND = "sandbox_orphan_cleanup_write_failed"; +// The fixed non-secret refusal a duplex channel open returns when the lease does +// not grant the opt-in `duplexCommandStream` capability. The service resolves the +// exact lease capability snapshot and throws this before it reaches any driver. +const DUPLEX_CHANNEL_CAPABILITY_DENIED = + "Sandbox lease does not grant the duplex command stream capability."; + // --------------------------------------------------------------------------- // Sandbox capability contract — one normalizer for both branches // --------------------------------------------------------------------------- @@ -90,6 +104,7 @@ export const SANDBOX_CAPABILITY_KEYS = [ "independentControlCommands", "incrementalSessionOutput", "concurrentSyncOperations", + "duplexCommandStream", ] as const; export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; @@ -106,6 +121,7 @@ export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; const SANDBOX_CAPABILITY_OPT_IN_KEYS: ReadonlySet = new Set([ "incrementalSessionOutput", "concurrentSyncOperations", + "duplexCommandStream", ]); /** @@ -142,6 +158,11 @@ const SANDBOX_CAPABILITY_OPT_IN_KEYS: ReadonlySet = new Se * verifies only one direction cannot get the capability. The verbs are * necessary but not sufficient: this key is opt-in, so the declaration is the * real gate (see {@link SANDBOX_CAPABILITY_OPT_IN_KEYS}). + * - `duplexCommandStream` requires `duplexChannelOpen`, the worker verb that + * opens the persistent duplex channel. The verified verb is necessary but not + * sufficient: this key is opt-in, so the declaration is the real gate. A + * provider that does not implement the duplex open verb resolves `false` and + * keeps the file bridge. */ const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record = { // Reusable leases require ALL reuse verbs. Each verb is its own required @@ -156,6 +177,7 @@ const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record; @@ -486,6 +518,15 @@ export interface EnvironmentRuntimeDriver { */ syncIn?(input: EnvironmentDriverSyncInput): Promise; syncOut?(input: EnvironmentDriverSyncInput): Promise; + /** + * Optional persistent duplex channel. Present only for a plugin-backed sandbox + * driver whose lease grants the `duplexCommandStream` capability. The driver + * opens the host-owned duplex route on the plugin worker and adapts it to the + * cross-layer {@link CommandManagedDuplexChannel}. Other drivers omit it. + */ + openDuplexChannel?( + input: EnvironmentDriverOpenDuplexChannelInput, + ): Promise; /** True when the lease's plugin worker advertises both sync verbs. */ supportsSync?(input: EnvironmentDriverLeaseInput): boolean; /** @@ -993,6 +1034,39 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { }; } +/** + * Adapt the worker manager's duplex host session to the cross-layer channel. The + * two shapes differ in the exit and stop members: the host session resolves the + * exit one time from `wait()`, so the channel bridges it to a one-time `onExit` + * listener; `kill()` maps to `stop()`. The `write`, `onData`, and `close` + * members map one to one. + */ +function adaptDuplexChannelHostSession( + session: DuplexChannelHostSession, +): CommandManagedDuplexChannel { + return { + write(data: string): void { + session.write(data); + }, + onData(listener: (chunk: string) => void): void { + session.onData(listener); + }, + onExit(listener: (exit: { exitCode: number | null }) => void): void { + // `wait()` resolves one time with the exit and never rejects, so a single + // `then` bridges it to the one-time exit listener. + void session.wait().then((exit) => { + listener(exit); + }); + }, + stop(): void { + session.kill(); + }, + close(): Promise { + return session.close(); + }, + }; +} + function createSandboxEnvironmentDriver( db: Db, options: { @@ -2369,6 +2443,37 @@ function createSandboxEnvironmentDriver( return await callPluginEnvironmentSync("environmentSyncOut", input); }, + async openDuplexChannel(input) { + // Plugin-backed sandbox providers only: open the host-owned duplex route on + // the plugin worker. The lease scope mirrors the sandbox execute path — the + // provider driver key, the company, the environment, and the provider lease + // id — so the route binds to the same worker session the runner streams. + if (!input.lease.metadata?.sandboxProviderPlugin || !pluginWorkerManager) { + throw new Error("Sandbox driver does not support duplex channels for this lease."); + } + const pluginId = readString(input.lease.metadata?.pluginId); + const providerKey = readString(input.lease.metadata?.provider); + const providerLeaseId = readString(input.lease.providerLeaseId); + if (!pluginId || !providerKey || !providerLeaseId) { + throw new Error( + "Sandbox duplex channel needs a plugin id, a provider key, and a provider lease id on the lease.", + ); + } + const worker = pluginWorkerManager.getWorker(pluginId); + if (!worker) { + throw new Error(`Plugin worker "${pluginId}" is not running for the duplex channel.`); + } + const managerInput: WorkerManagerDuplexChannelOpenInput = { + driverKey: providerKey, + companyId: input.lease.companyId, + environmentId: input.environment.id, + providerLeaseId, + command: input.command, + }; + const session = await worker.openDuplexChannel(managerInput); + return adaptDuplexChannelHostSession(session); + }, + async effectiveSandboxCapabilities(input) { const metadata = input.lease.metadata ?? {}; const providerKey = @@ -3320,6 +3425,27 @@ export function environmentRuntimeService( } return await driver.syncOut(input); }, + + async openDuplexChannel( + input: EnvironmentDriverOpenDuplexChannelInput, + ): Promise { + const driver = requireDriverKey(getLeaseDriverKey(input.lease, input.environment)); + if (!driver.openDuplexChannel) { + throw new Error(`Environment driver "${driver.driver}" does not support duplex channels.`); + } + // Centralize the duplex channel authorization here. Resolve the exact lease + // capability snapshot and refuse unless the effective snapshot grants the + // opt-in `duplexCommandStream` capability. This gate runs before the driver + // call, so an unauthorized lease never reaches the worker. The + // execution-target member gate stays as defense in depth. A driver that + // cannot resolve the snapshot fails closed with the fixed refusal. + const effective = + (await driver.effectiveSandboxCapabilities?.(input)) ?? null; + if (effective?.duplexCommandStream !== true) { + throw new Error(DUPLEX_CHANNEL_CAPABILITY_DENIED); + } + return await driver.openDuplexChannel(input); + }, }; } diff --git a/server/src/services/plugin-worker-manager.ts b/server/src/services/plugin-worker-manager.ts index 5006a9c5c6a..8c54cdbeb26 100644 --- a/server/src/services/plugin-worker-manager.ts +++ b/server/src/services/plugin-worker-manager.ts @@ -39,6 +39,8 @@ import { JsonRpcCallError, SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION, SETUP_TOKEN_PTY_EXIT_NOTIFICATION, + DUPLEX_CHANNEL_DATA_NOTIFICATION, + DUPLEX_CHANNEL_EXIT_NOTIFICATION, } from "@paperclipai/plugin-sdk"; import type { JsonRpcId, @@ -156,6 +158,60 @@ const SETUP_TOKEN_PTY_ROUTE_BUSY = "SETUP_TOKEN_PTY_ROUTE_BUSY"; /** The fixed non-secret error a failed open returns. */ const SETUP_TOKEN_PTY_OPEN_FAILED = "SETUP_TOKEN_PTY_OPEN_FAILED"; +// Bounds and timeouts for the generic duplex channel route. The route mirrors the +// login pseudo-terminal route, but it carries no command allowlist and adds seven +// explicit bounds the pseudo-terminal route lacks. Each bound ends the route when +// it passes the limit, so a faulty or hostile worker cannot flood the host. +/** The default maximum characters for one duplex channel data notification. */ +const MAX_DUPLEX_CHANNEL_CHUNK_CHARS = 1_000_000; +/** + * The default maximum cumulative characters the host buffers for one duplex + * channel route before a data listener attaches. A worker that streams data + * before the consumer binds cannot grow the host buffer without limit. + */ +const MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS = 8 * 1024 * 1024; +/** + * The default maximum number of data frames the host buffers for one duplex + * channel route before a data listener attaches. + */ +const MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES = 10_000; +/** + * The default maximum number of in-flight host→worker requests for one duplex + * channel route. A worker that never replies cannot make the host hold an + * unbounded number of pending requests. + */ +const MAX_DUPLEX_CHANNEL_PENDING_REQUESTS = 256; +/** The default maximum characters for one host→worker duplex channel write. */ +const MAX_DUPLEX_CHANNEL_WRITE_CHARS = 1_000_000; +/** + * The default maximum number of protocol errors for one duplex channel route. + * A protocol error is one malformed or mismatched data frame. The route ends + * when the count passes this budget, so a flood of bad frames bounds the route. + */ +const MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS = 100; +/** + * The default maximum cumulative bytes the host forwards for one duplex channel + * route over its whole life. The host counts the bytes of every inbound chunk, + * before and after a data listener attaches. The route ends when the count + * passes this cap, so an active route with a bound listener cannot stream an + * unbounded number of bytes. + */ +const MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES = 256 * 1024 * 1024; +/** + * The default maximum lifetime for one duplex channel route, in milliseconds. + * The host starts a timer when the route opens and ends the route when the + * timer expires, so a route cannot live without limit. + */ +const MAX_DUPLEX_CHANNEL_DURATION_MS = 60 * 60 * 1000; +/** The default open timeout for one duplex channel route, in milliseconds. */ +const DUPLEX_CHANNEL_OPEN_TIMEOUT_MS = 30_000; +/** The default close timeout for one duplex channel route, in milliseconds. */ +const DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS = 10_000; +/** The fixed non-secret error a rejected second duplex channel open returns. */ +const DUPLEX_CHANNEL_ROUTE_BUSY = "DUPLEX_CHANNEL_ROUTE_BUSY"; +/** The fixed non-secret error a failed duplex channel open returns. */ +const DUPLEX_CHANNEL_OPEN_FAILED = "DUPLEX_CHANNEL_OPEN_FAILED"; + /** 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. */ @@ -322,6 +378,36 @@ export interface WorkerStartOptions { /** The close timeout for one login pseudo-terminal route, in milliseconds. */ closeTimeoutMs?: number; }; + + /** + * Bounds and timeouts for the generic duplex channel route. The defaults bound + * one data notification, the pre-bind buffer, the in-flight request count, one + * host→worker write, the protocol-error budget, and the open and close + * timeouts. A test overrides them to exercise each bound without huge inputs or + * long waits. + */ + duplexChannelLimits?: { + /** Max characters for one duplex channel data notification. */ + maxChunkChars?: number; + /** Max cumulative characters the host buffers before a data listener attaches. */ + maxPreBindBufferedChars?: number; + /** Max number of data frames the host buffers before a data listener attaches. */ + maxPreBindBufferedFrames?: number; + /** Max number of in-flight host→worker requests for one route. */ + maxPendingRequests?: number; + /** Max characters for one host→worker duplex channel write. */ + maxWriteChars?: number; + /** Max number of protocol errors for one route before the route ends. */ + maxProtocolErrors?: number; + /** Max cumulative bytes the host forwards for one route over its whole life. */ + maxTotalDataBytes?: number; + /** The maximum lifetime for one route, in milliseconds. */ + maxDurationMs?: number; + /** The open timeout for one duplex channel route, in milliseconds. */ + openTimeoutMs?: number; + /** The close timeout for one duplex channel route, in milliseconds. */ + closeTimeoutMs?: number; + }; } /** @@ -393,6 +479,38 @@ export interface SetupTokenPtyHostSession { close(): Promise; } +/** + * The input the manager needs to open one generic duplex channel. The manager + * mints the host route identifier. The caller supplies the sandbox scope, the + * provider lease id, and the command. The duplex channel carries no command + * allowlist, so the caller owns the command. + */ +export interface DuplexChannelOpenInput { + driverKey: string; + companyId: string; + environmentId: string; + providerLeaseId: string; + command: string; +} + +/** + * One live duplex channel the manager hands to a caller. The shape matches the + * login pseudo-terminal session, so a caller consumes one live bidirectional + * stream with the same methods. + */ +export interface DuplexChannelHostSession { + /** Registers the one data listener. The session streams each raw chunk in order. */ + onData(listener: (chunk: string) => void): void; + /** Writes raw input bytes to the channel. */ + write(data: string): void; + /** Resolves with the child exit code when the command ends or the route ends. */ + wait(): Promise<{ exitCode: number | null }>; + /** Stops the child process. Safe to call more than one time. */ + kill(): void; + /** Closes the route and releases the channel. Safe to call more than one time. */ + close(): 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 @@ -484,6 +602,17 @@ export interface PluginWorkerHandle { input: SetupTokenPtyOpenInput, ): Promise; + /** + * Open one generic duplex channel on this worker. The manager mints the host + * route identifier, reserves the route, drives the open, binds the worker + * session identifier one time, and returns a session a caller drives. It + * permits one active duplex channel per worker. It enforces five explicit + * bounds and ends the route when a bound passes its limit. + */ + openDuplexChannel( + input: DuplexChannelOpenInput, + ): Promise; + /** * Authorize the set of companies this worker may act on from proactive * (non-invocation) context. Replaces any previously-authorized set. See the @@ -667,6 +796,34 @@ export function createPluginWorkerHandle( const setupTokenPtyCloseTimeoutMs = options.setupTokenPtyLimits?.closeTimeoutMs ?? SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS; + // Bounds and timeouts for the generic duplex channel route. A caller (a test) + // can lower them to exercise each bound and the terminalize paths. + const maxDuplexChannelChunkChars = + options.duplexChannelLimits?.maxChunkChars ?? MAX_DUPLEX_CHANNEL_CHUNK_CHARS; + const maxDuplexChannelPreBindChars = + options.duplexChannelLimits?.maxPreBindBufferedChars ?? + MAX_DUPLEX_CHANNEL_PRE_BIND_CHARS; + const maxDuplexChannelPreBindFrames = + options.duplexChannelLimits?.maxPreBindBufferedFrames ?? + MAX_DUPLEX_CHANNEL_PRE_BIND_FRAMES; + const maxDuplexChannelPendingRequests = + options.duplexChannelLimits?.maxPendingRequests ?? + MAX_DUPLEX_CHANNEL_PENDING_REQUESTS; + const maxDuplexChannelWriteChars = + options.duplexChannelLimits?.maxWriteChars ?? MAX_DUPLEX_CHANNEL_WRITE_CHARS; + const maxDuplexChannelProtocolErrors = + options.duplexChannelLimits?.maxProtocolErrors ?? + MAX_DUPLEX_CHANNEL_PROTOCOL_ERRORS; + const maxDuplexChannelTotalDataBytes = + options.duplexChannelLimits?.maxTotalDataBytes ?? + MAX_DUPLEX_CHANNEL_TOTAL_DATA_BYTES; + const maxDuplexChannelDurationMs = + options.duplexChannelLimits?.maxDurationMs ?? MAX_DUPLEX_CHANNEL_DURATION_MS; + const duplexChannelOpenTimeoutMs = + options.duplexChannelLimits?.openTimeoutMs ?? DUPLEX_CHANNEL_OPEN_TIMEOUT_MS; + const duplexChannelCloseTimeoutMs = + options.duplexChannelLimits?.closeTimeoutMs ?? DUPLEX_CHANNEL_CLOSE_TIMEOUT_MS; + // ------------------------------------------------------------------ // Proactive company scopes (LOOA-629) // ------------------------------------------------------------------ @@ -1039,7 +1196,42 @@ export function createPluginWorkerHandle( // verifies a close acknowledgement bound to that identifier; it retires the // worker on an unconfirmed close. - type SetupTokenPtyRouteState = "reserved" | "opening" | "open" | "closed"; + // A single-consumer route state. The login pseudo-terminal route and the + // generic duplex channel route share it. + type RouteState = "reserved" | "opening" | "open" | "closed"; + + // Shared route-binding helpers. The login pseudo-terminal route and the duplex + // channel route both use them, so the two routes bind and settle one way. + + // Settle the route wait exactly once. Replace the settler with a no-op, so a + // later exit or terminalize never settles the wait a second time. + function settleRouteWait( + route: { settleWait: (value: { exitCode: number | null }) => void }, + value: { exitCode: number | null }, + ): void { + const settle = route.settleWait; + route.settleWait = () => {}; + settle(value); + } + + // Read the worker session identifier from an open reply, but only when the + // route can still bind. Return null for a malformed reply, or for a route that + // already left `opening` or terminalized. A late or a duplicate reply never + // binds, revives, or reopens a route. + function readBindableWorkerSessionId( + route: { state: RouteState; terminalized: boolean }, + openResult: unknown, + ): string | null { + const workerSessionId = readNonEmptyString( + isRecord(openResult) ? openResult.workerSessionId : null, + ); + if (!workerSessionId || route.state !== "opening" || route.terminalized) { + return null; + } + return workerSessionId; + } + + type SetupTokenPtyRouteState = RouteState; interface SetupTokenPtyRoute { hostRouteId: string; state: SetupTokenPtyRouteState; @@ -1054,15 +1246,6 @@ export function createPluginWorkerHandle( // blocks a second open until the manager confirms the first route's close. let setupTokenPtyRoute: SetupTokenPtyRoute | null = null; - function settleSetupTokenPtyWait( - route: SetupTokenPtyRoute, - value: { exitCode: number | null }, - ): void { - const settle = route.settleWait; - route.settleWait = () => {}; - settle(value); - } - // Close the worker terminal by the host route identifier and verify the bound // acknowledgement. Return true only when the worker returns an acknowledgement // that carries the exact host route identifier. An absent, malformed, @@ -1092,7 +1275,7 @@ export function createPluginWorkerHandle( route.buffered = []; // A terminalized route reports a null exit code, which the runner treats as a // failure. - settleSetupTokenPtyWait(route, { exitCode: null }); + settleRouteWait(route, { exitCode: null }); const confirmed = await closeSetupTokenPtyTerminal(route.hostRouteId); if (setupTokenPtyRoute === route) setupTokenPtyRoute = null; if (!confirmed) { @@ -1144,7 +1327,7 @@ export function createPluginWorkerHandle( const workerSessionId = readNonEmptyString(params.workerSessionId); if (!workerSessionId || workerSessionId !== route.workerSessionId) return; const exitCode = typeof params.exitCode === "number" ? params.exitCode : null; - settleSetupTokenPtyWait(route, { exitCode }); + settleRouteWait(route, { exitCode }); } // Close the one route on a worker exit. The worker is gone, so the manager @@ -1158,7 +1341,7 @@ export function createPluginWorkerHandle( route.state = "closed"; route.listener = null; route.buffered = []; - settleSetupTokenPtyWait(route, { exitCode: null }); + settleRouteWait(route, { exitCode: null }); } // Open one live login pseudo-terminal route. Reserve the route @@ -1219,10 +1402,8 @@ export function createPluginWorkerHandle( throw err instanceof Error ? err : new Error(SETUP_TOKEN_PTY_OPEN_FAILED); } - const workerSessionId = readNonEmptyString( - isRecord(openResult) ? openResult.workerSessionId : null, - ); - if (!workerSessionId || route.state !== "opening" || route.terminalized) { + const workerSessionId = readBindableWorkerSessionId(route, openResult); + if (!workerSessionId) { // A malformed reply, or a route that already left `opening`. A late or a // duplicate reply never binds, revives, or reopens a route. await terminalizeSetupTokenPtyRoute(route); @@ -1268,6 +1449,345 @@ export function createPluginWorkerHandle( }; } + // ----------------------------------------------------------------------- + // Host-owned generic duplex channel route + // ----------------------------------------------------------------------- + // The duplex channel route mirrors the login pseudo-terminal route model. The + // host owns the route identifier, binds the worker session identifier one time + // on a valid open reply, and keys the close on the host route identifier. It + // rejects a late or a duplicate open reply, and it retires the worker on an + // unconfirmed close. The duplex channel carries no command allowlist, so the + // caller owns the command. + // + // The route adds seven explicit bounds the pseudo-terminal route lacks. Each + // bound ends the route when it passes its limit: + // 1. pre-bind buffered bytes — the cumulative characters the host buffers + // before a data listener attaches; + // 2. pre-bind buffered frame count — the number of data frames the host + // buffers before a data listener attaches; + // 3. pending request count — the number of in-flight host→worker requests; + // 4. host→worker write size — the characters for one write; + // 5. protocol error rate — the count of malformed or mismatched data frames; + // 6. total data bytes — the cumulative inbound bytes over the whole life, + // counted before and after a data listener attaches; + // 7. route lifetime — the milliseconds from the open to the terminal end. + interface DuplexChannelRoute { + hostRouteId: string; + state: RouteState; + workerSessionId: string | null; + listener: ((chunk: string) => void) | null; + buffered: string[]; + bufferedChars: number; + pendingRequests: number; + protocolErrors: number; + totalDataBytes: number; + lifetimeTimer: ReturnType | null; + terminalized: boolean; + settleWait: (value: { exitCode: number | null }) => void; + } + // At most one active duplex channel per worker. A non-null route blocks a + // second open until the manager confirms the first route's close. + let duplexChannelRoute: DuplexChannelRoute | null = null; + + // Close the worker channel by the host route identifier and verify the bound + // acknowledgement. Return true only when the worker returns an acknowledgement + // that carries the exact host route identifier. An absent, malformed, + // mismatched, or timed-out acknowledgement returns false, so the caller fails + // closed. + async function closeDuplexChannelTerminal(hostRouteId: string): Promise { + try { + const ack = await callInternal( + "duplexChannelClose", + { hostRouteId }, + duplexChannelCloseTimeoutMs, + ); + return isRecord(ack) && readNonEmptyString(ack.hostRouteId) === hostRouteId; + } catch { + return false; + } + } + + // Terminalize the route exactly once. Resolve the wait, close the worker + // channel by the host route identifier, and free the per-worker slot only + // after the close resolves. Retire the worker when the close is unconfirmed. + // Clear the route lifetime timer one time. Every terminal path and the + // worker-exit path calls this, so a timer never fires after the route ends. + function clearDuplexChannelLifetimeTimer(route: DuplexChannelRoute): void { + if (route.lifetimeTimer) { + clearTimeout(route.lifetimeTimer); + route.lifetimeTimer = null; + } + } + + async function terminalizeDuplexChannelRoute(route: DuplexChannelRoute): Promise { + if (route.terminalized) return; + route.terminalized = true; + route.state = "closed"; + route.listener = null; + route.buffered = []; + route.bufferedChars = 0; + clearDuplexChannelLifetimeTimer(route); + // A terminalized route reports a null exit code, which the caller treats as a + // failure. + settleRouteWait(route, { exitCode: null }); + const confirmed = await closeDuplexChannelTerminal(route.hostRouteId); + if (duplexChannelRoute === route) duplexChannelRoute = null; + if (!confirmed) { + // The worker did not acknowledge the close, so the host cannot prove the + // channel is gone. Fail closed: retire the worker before any reuse. + log.error( + { pluginId }, + "duplex channel close not acknowledged; retiring worker", + ); + void killProcess(); + } + } + + // Count one protocol error for the route. End the route when the count passes + // the per-route budget, so a flood of malformed or mismatched frames bounds the + // route. + function recordDuplexChannelProtocolError(route: DuplexChannelRoute): void { + route.protocolErrors += 1; + if (route.protocolErrors > maxDuplexChannelProtocolErrors) { + void terminalizeDuplexChannelRoute(route); + } + } + + // Deliver one duplex channel chunk to the bound listener in isolation. A + // listener that throws must not escape the worker stdout notification handler + // or the buffered replay, so a throw here breaks neither the notification + // dispatch loop nor the pre-bind drain. The manager catches the error and logs + // it without the raw bytes. This mirrors the `execute.log` delivery isolation. + function deliverDuplexChannelChunk( + listener: (chunk: string) => void, + chunk: string, + ): void { + try { + listener(chunk); + } catch (err) { + log.error( + { err: err instanceof Error ? err.message : String(err) }, + "duplex channel data delivery threw", + ); + } + } + + // Route one duplex channel data notification to the per-session listener. + // Deliver only while the route is `open` and the notification carries the exact + // bound worker session identifier and a valid chunk. Count a mismatched or + // malformed frame as a protocol error. End the route at once when one chunk is + // larger than the per-chunk limit or when the cumulative bytes pass the total + // cap. Buffer a valid frame under the pre-bind bounds when no listener has + // attached yet. Never log the raw bytes. + function routeDuplexChannelData(notification: JsonRpcNotification): void { + const route = duplexChannelRoute; + if (!route || route.state !== "open") return; + const params = isRecord(notification.params) ? notification.params : {}; + const workerSessionId = readNonEmptyString(params.workerSessionId); + const chunk = params.chunk; + if ( + !workerSessionId || + workerSessionId !== route.workerSessionId || + typeof chunk !== "string" || + chunk.length === 0 + ) { + // A late, unknown, malformed, or mismatched frame. Drop it and count one + // protocol error. + recordDuplexChannelProtocolError(route); + return; + } + if (chunk.length > maxDuplexChannelChunkChars) { + // One inbound chunk is larger than the per-chunk limit. End the route at + // once. Do not count the chunk as a protocol error. + void terminalizeDuplexChannelRoute(route); + return; + } + // Count the bytes of the chunk. Enforce the cumulative total-byte cap before + // and after a listener attaches. End the route when the cap is exceeded, so a + // bound listener cannot receive data past the cap. + const chunkBytes = Buffer.byteLength(chunk); + if (route.totalDataBytes + chunkBytes > maxDuplexChannelTotalDataBytes) { + void terminalizeDuplexChannelRoute(route); + return; + } + route.totalDataBytes += chunkBytes; + if (route.listener) { + deliverDuplexChannelChunk(route.listener, chunk); + return; + } + // No listener attached yet. Buffer the frame under the pre-bind bounds. End + // the route when the cumulative bytes or the frame count passes the bound. + if ( + route.buffered.length + 1 > maxDuplexChannelPreBindFrames || + route.bufferedChars + chunk.length > maxDuplexChannelPreBindChars + ) { + void terminalizeDuplexChannelRoute(route); + return; + } + route.buffered.push(chunk); + route.bufferedChars += chunk.length; + } + + // Route one duplex channel exit notification to the wait. Resolve only while + // the route is `open` and the notification carries the exact bound worker + // session identifier. + function routeDuplexChannelExit(notification: JsonRpcNotification): void { + const route = duplexChannelRoute; + if (!route || route.state !== "open") return; + const params = isRecord(notification.params) ? notification.params : {}; + const workerSessionId = readNonEmptyString(params.workerSessionId); + if (!workerSessionId || workerSessionId !== route.workerSessionId) return; + const exitCode = typeof params.exitCode === "number" ? params.exitCode : null; + settleRouteWait(route, { exitCode }); + } + + // Close the one route on a worker exit. The worker is gone, so the manager + // resolves the wait with the fixed non-secret exit and clears the route one + // time. The pending channel calls reject through `rejectAllPending`. + function closeDuplexChannelRouteOnWorkerExit(): void { + const route = duplexChannelRoute; + if (!route) return; + duplexChannelRoute = null; + route.terminalized = true; + route.state = "closed"; + route.listener = null; + route.buffered = []; + route.bufferedChars = 0; + clearDuplexChannelLifetimeTimer(route); + settleRouteWait(route, { exitCode: null }); + } + + // Open one live generic duplex channel route. Reserve the route before the open + // call, bind the worker session identifier one time on the first successful + // open reply, and return a session a caller drives. Terminalize the route on + // every open failure path. + async function openDuplexChannel( + input: DuplexChannelOpenInput, + ): Promise { + if (duplexChannelRoute) { + // A route for this worker is not yet closed and confirmed. Reject the + // second open with one fixed non-secret error before it reaches the worker. + throw new Error(DUPLEX_CHANNEL_ROUTE_BUSY); + } + const hostRouteId = randomUUID(); + let settleWait: (value: { exitCode: number | null }) => void = () => {}; + const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => { + settleWait = resolve; + }); + const route: DuplexChannelRoute = { + hostRouteId, + state: "reserved", + workerSessionId: null, + listener: null, + buffered: [], + bufferedChars: 0, + pendingRequests: 0, + protocolErrors: 0, + totalDataBytes: 0, + lifetimeTimer: null, + terminalized: false, + settleWait, + }; + duplexChannelRoute = route; + + route.state = "opening"; + let openResult: HostToWorkerMethods["duplexChannelOpen"][1]; + try { + openResult = await callInternal( + "duplexChannelOpen", + { + hostRouteId, + driverKey: input.driverKey, + companyId: input.companyId, + environmentId: input.environmentId, + providerLeaseId: input.providerLeaseId, + command: input.command, + }, + duplexChannelOpenTimeoutMs, + ); + } catch (err) { + // A send failure, an RPC rejection, or an open timeout. Terminalize the + // route exactly once and fail closed. + await terminalizeDuplexChannelRoute(route); + throw err instanceof Error ? err : new Error(DUPLEX_CHANNEL_OPEN_FAILED); + } + + const workerSessionId = readBindableWorkerSessionId(route, openResult); + if (!workerSessionId) { + // A malformed reply, or a route that already left `opening`. A late or a + // duplicate reply never binds, revives, or reopens a route. + await terminalizeDuplexChannelRoute(route); + throw new Error(DUPLEX_CHANNEL_OPEN_FAILED); + } + // Bind the worker session identifier one time and move the route to `open`. + route.workerSessionId = workerSessionId; + route.state = "open"; + + // Start the route lifetime timer now the route is open. The route ends when + // the timer expires. Every terminal path and the worker-exit path clears the + // timer. Unreference the timer so it never blocks the host process shutdown. + route.lifetimeTimer = setTimeout(() => { + void terminalizeDuplexChannelRoute(route); + }, maxDuplexChannelDurationMs); + route.lifetimeTimer.unref?.(); + + // Send one host→worker request under the pending-request bound. End the route + // when too many requests are in-flight, so a worker that never replies cannot + // make the host hold an unbounded number of pending requests. + const sendBoundedRequest = < + M extends "duplexChannelWrite" | "duplexChannelStop", + >( + method: M, + params: HostToWorkerMethods[M][0], + ): void => { + if (route.state !== "open") return; + if (route.pendingRequests >= maxDuplexChannelPendingRequests) { + void terminalizeDuplexChannelRoute(route); + return; + } + route.pendingRequests += 1; + void callInternal(method, params, duplexChannelOpenTimeoutMs) + .catch(() => {}) + .finally(() => { + route.pendingRequests -= 1; + }); + }; + + return { + onData(listener: (chunk: string) => void): void { + route.listener = listener; + if (route.buffered.length > 0) { + const pending = route.buffered; + route.buffered = []; + route.bufferedChars = 0; + for (const chunk of pending) deliverDuplexChannelChunk(listener, chunk); + } + }, + write(data: string): void { + const sid = route.workerSessionId; + if (route.state !== "open" || !sid) return; + if (data.length > maxDuplexChannelWriteChars) { + // The write is larger than the size bound. End the route before the + // write reaches the worker. + void terminalizeDuplexChannelRoute(route); + return; + } + sendBoundedRequest("duplexChannelWrite", { workerSessionId: sid, data }); + }, + wait(): Promise<{ exitCode: number | null }> { + return waitPromise; + }, + kill(): void { + const sid = route.workerSessionId; + if (!sid) return; + sendBoundedRequest("duplexChannelStop", { workerSessionId: sid }); + }, + async close(): Promise { + await terminalizeDuplexChannelRoute(route); + }, + }; + } + /** * Extract the single company a worker→host call references, mirroring the SDK * governed-access gate's own derivation (host-client-factory.ts @@ -1440,6 +1960,18 @@ export function createPluginWorkerHandle( return; } + // Duplex channel notifications: deliver data and the exit to the one + // host-owned duplex route, bound by the worker session identifier while the + // route is open. + if (notification.method === DUPLEX_CHANNEL_DATA_NOTIFICATION) { + routeDuplexChannelData(notification); + return; + } + if (notification.method === DUPLEX_CHANNEL_EXIT_NOTIFICATION) { + routeDuplexChannelExit(notification); + return; + } + // Stream notifications: forward to the stream bus via callback if ( notification.method === "streams.open" || @@ -1590,6 +2122,10 @@ export function createPluginWorkerHandle( // already rejected through `rejectAllPending`. closeSetupTokenPtyRouteOnWorkerExit(); + // Close the one duplex channel route the same way. The pending channel calls + // already rejected through `rejectAllPending`. + closeDuplexChannelRouteOnWorkerExit(); + // Emit synthetic close for any orphaned stream channels so SSE clients // are notified instead of hanging indefinitely. if (openStreamChannels.size > 0 && options.onStreamNotification) { @@ -2063,6 +2599,17 @@ export function createPluginWorkerHandle( return openSetupTokenPtySession(input); }, + openDuplexChannel(input: DuplexChannelOpenInput) { + if (status !== "running" && status !== "starting") { + return Promise.reject( + new Error( + `Cannot open a duplex channel — worker for "${pluginId}" is ${status}`, + ), + ); + } + return openDuplexChannel(input); + }, + notify(method: string, params: unknown) { if (status !== "running") return; const invocationScope = deriveInvocationScope(method, params); From 01ddc26a3731892abd6328c44fb634b6f737fa43 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:43:26 -0500 Subject: [PATCH 39/39] fix(routines): clear transient execution failures (#9689) 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 > - Scheduled routines track each dispatch in `routine_runs` and link it to an execution issue > - Moving an execution issue to `blocked` or `cancelled` correctly records a failed run state for operator visibility > - When that issue later resumes or completes, the run can retain the earlier failure reason and completion timestamp > - That stale state makes an active or successfully completed routine appear failed > - This pull request reconciles the run back to a live state on resume and preserves cleared failure details as completion context > - The benefit is that routine run status consistently reflects the current execution issue lifecycle without losing useful recovery history ## Linked Issues or Issue Description Refs #9201 ### What happened? A routine execution issue that temporarily moved to `blocked` or `cancelled` caused its linked routine run to become `failed`. If the issue later returned to an active status or reached `done`, the routine run could keep the stale failure reason and terminal timestamp. ### Expected behavior Active execution issues should have an `issue_created` run with no failure or completion timestamp. Completed execution issues should have a `completed` run with no active failure reason, while retaining any earlier transient failure in structured trigger context for diagnosis. ### Steps to reproduce 1. Create a routine run linked to a routine execution issue. 2. Move the issue to `blocked` and synchronize the run state. 3. Move the issue back to `in_progress` or forward to `done` and synchronize again. 4. Observe that the run previously retained stale failed-state fields. ### Environment - Reproduced on `master` at `da549123cc`. - Core server behavior; not adapter-specific. - Covered with the embedded PostgreSQL routines service test harness. ## What Changed - Load the linked routine run while synchronizing execution issue status. - Restore transiently failed runs to `issue_created` when their execution issue resumes active work. - Clear stale failure state when an execution issue completes and retain the earlier failure under `triggerPayload.transientFailure`. - Add regression coverage for both resumed and completed execution issues. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/routines-service.test.ts` — 57 tests passed. - `pnpm --filter @paperclipai/server typecheck` — passed. ## Risks - Low risk: the change is limited to routine execution issue/run reconciliation. - A completed run now stores a prior failed-state reason as structured transient context instead of leaving `failureReason` populated. - No schema, migration, API contract, or UI behavior changes are included. > 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 using GPT-5.4 with reasoning, repository tools, GitHub CLI access, code execution, and focused test execution. 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 --- server/src/__tests__/routines-service.test.ts | 130 ++++++++++++++++++ server/src/services/routines.ts | 91 +++++++++++- 2 files changed, 220 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 772747a8735..cbdf42ee70c 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -239,6 +239,136 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { .then((rows) => rows[0]!); } + it("clears transient routine run failures when execution issues resume", async () => { + const { companyId, issueSvc, routine, svc } = await seedFixture(); + const runId = randomUUID(); + const executionIssue = await issueSvc.create(companyId, { + projectId: routine.projectId, + title: routine.title, + description: routine.description, + status: "blocked", + priority: routine.priority, + assigneeAgentId: routine.assigneeAgentId, + originKind: "routine_execution", + originId: routine.id, + originRunId: runId, + }); + + await db.insert(routineRuns).values({ + id: runId, + companyId, + routineId: routine.id, + source: "manual", + status: "issue_created", + triggeredAt: new Date("2026-07-16T12:00:00.000Z"), + linkedIssueId: executionIssue.id, + }); + + await svc.syncRunStatusForIssue(executionIssue.id); + const [failedRun] = await db.select().from(routineRuns).where(eq(routineRuns.id, runId)); + expect(failedRun).toMatchObject({ + status: "failed", + failureReason: "Execution issue moved to blocked", + triggerPayload: { + transientFailure: { + code: "execution_issue_status", + status: "blocked", + }, + }, + }); + await db.update(issues).set({ status: "in_progress" }).where(eq(issues.id, executionIssue.id)); + await svc.syncRunStatusForIssue(executionIssue.id); + + const [run] = await db.select().from(routineRuns).where(eq(routineRuns.id, runId)); + expect(run).toMatchObject({ + status: "issue_created", + failureReason: null, + completedAt: null, + triggerPayload: { + transientFailure: { + code: "execution_issue_status", + status: "blocked", + clearedAt: expect.any(String), + }, + }, + }); + + const clearedAt = (run?.triggerPayload as { transientFailure?: { clearedAt?: string } } | null) + ?.transientFailure?.clearedAt; + expect(clearedAt).toEqual(expect.any(String)); + + await db.update(issues).set({ status: "done" }).where(eq(issues.id, executionIssue.id)); + await svc.syncRunStatusForIssue(executionIssue.id); + + const [completedRun] = await db.select().from(routineRuns).where(eq(routineRuns.id, runId)); + expect(completedRun).toMatchObject({ + status: "completed", + failureReason: null, + triggerPayload: { + transientFailure: { + code: "execution_issue_status", + status: "blocked", + clearedAt, + }, + }, + }); + expect(completedRun?.completedAt).toBeInstanceOf(Date); + }); + + it("moves transient routine run failures into completion context", async () => { + const { companyId, issueSvc, routine, svc } = await seedFixture(); + const runId = randomUUID(); + const executionIssue = await issueSvc.create(companyId, { + projectId: routine.projectId, + title: routine.title, + description: routine.description, + status: "blocked", + priority: routine.priority, + assigneeAgentId: routine.assigneeAgentId, + originKind: "routine_execution", + originId: routine.id, + originRunId: runId, + }); + + await db.insert(routineRuns).values({ + id: runId, + companyId, + routineId: routine.id, + source: "manual", + status: "issue_created", + triggeredAt: new Date("2026-07-16T12:00:00.000Z"), + linkedIssueId: executionIssue.id, + triggerPayload: { input: "preserved" }, + }); + + await svc.syncRunStatusForIssue(executionIssue.id); + const [failedRun] = await db.select().from(routineRuns).where(eq(routineRuns.id, runId)); + expect(failedRun).toMatchObject({ + status: "failed", + failureReason: "Execution issue moved to blocked", + }); + await db.update(issues).set({ status: "done" }).where(eq(issues.id, executionIssue.id)); + await svc.syncRunStatusForIssue(executionIssue.id); + + const [run] = await db.select().from(routineRuns).where(eq(routineRuns.id, runId)); + expect(run).toMatchObject({ + status: "completed", + failureReason: null, + triggerPayload: { + input: "preserved", + transientFailure: { + code: "execution_issue_status", + status: "blocked", + reason: "Execution issue moved to blocked", + }, + }, + }); + expect(run?.completedAt).toBeInstanceOf(Date); + expect(run?.triggerPayload).toMatchObject({ + transientFailure: { clearedAt: expect.any(String) }, + }); + }); + it("filters listed routines by project", async () => { const { companyId, agentId, projectId, routine, svc } = await seedFixture(); const otherProjectId = randomUUID(); diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index 95f7a3e06bb..f09ac75821d 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -83,6 +83,8 @@ const LIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running", "scheduled_retry"]; const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); const MAX_CATCH_UP_RUNS = 25; const MAX_ROUTINE_REVISIONS = 100; +const EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE = "execution_issue_status"; +const EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES = ["blocked", "cancelled"] as const; const ACTIVITY_GATE_IGNORED_ACTIONS = [ "issue.read_marked", "issue.read_unmarked", @@ -100,6 +102,37 @@ const WEEKDAY_INDEX: Record = { Sat: 6, }; +type ExecutionIssueTransientFailureStatus = (typeof EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES)[number]; + +function executionIssueTransientFailureReason(status: ExecutionIssueTransientFailureStatus) { + return `Execution issue moved to ${status}`; +} + +function executionIssueTransientFailureStatusFromPayload(payload: unknown): ExecutionIssueTransientFailureStatus | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const transientFailure = (payload as Record).transientFailure; + if (!transientFailure || typeof transientFailure !== "object" || Array.isArray(transientFailure)) return null; + const record = transientFailure as Record; + if (record.code !== EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE) return null; + return EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES.find((status) => record.status === status) ?? null; +} + +function executionIssueTransientFailureClearedAtFromPayload(payload: unknown): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const transientFailure = (payload as Record).transientFailure; + if (!transientFailure || typeof transientFailure !== "object" || Array.isArray(transientFailure)) return null; + const clearedAt = (transientFailure as Record).clearedAt; + return typeof clearedAt === "string" ? clearedAt : null; +} + +function legacyExecutionIssueTransientFailureStatus( + failureReason: string | null, +): ExecutionIssueTransientFailureStatus | null { + return EXECUTION_ISSUE_TRANSIENT_FAILURE_STATUSES.find( + (status) => failureReason === executionIssueTransientFailureReason(status), + ) ?? null; +} + async function resolveCompanyDefaultResponsibleUserId(db: Db, companyId: string) { const company = await db .select({ defaultResponsibleUserId: companies.defaultResponsibleUserId }) @@ -3144,17 +3177,73 @@ export function routineService( .where(eq(issues.id, issueId)) .then((rows) => rows[0] ?? null); if (!issue || issue.originKind !== "routine_execution" || !issue.originRunId) return null; + const run = await db + .select({ + id: routineRuns.id, + status: routineRuns.status, + failureReason: routineRuns.failureReason, + triggerPayload: routineRuns.triggerPayload, + }) + .from(routineRuns) + .where(eq(routineRuns.id, issue.originRunId)) + .then((rows) => rows[0] ?? null); + if (!run) return null; if (issue.status === "done") { + const transientFailureStatus = executionIssueTransientFailureStatusFromPayload(run.triggerPayload) + ?? legacyExecutionIssueTransientFailureStatus(run.failureReason); + const transientFailureClearedAt = executionIssueTransientFailureClearedAtFromPayload(run.triggerPayload); return finalizeRun(issue.originRunId, { status: "completed", + failureReason: null, completedAt: new Date(), + ...(transientFailureStatus + ? { + triggerPayload: { + ...(run.triggerPayload ?? {}), + transientFailure: { + code: EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE, + status: transientFailureStatus, + reason: executionIssueTransientFailureReason(transientFailureStatus), + clearedAt: transientFailureClearedAt ?? new Date().toISOString(), + }, + }, + } + : {}), }); } if (issue.status === "blocked" || issue.status === "cancelled") { + const failureReason = executionIssueTransientFailureReason(issue.status); return finalizeRun(issue.originRunId, { status: "failed", - failureReason: `Execution issue moved to ${issue.status}`, + failureReason, completedAt: new Date(), + triggerPayload: { + ...(run.triggerPayload ?? {}), + transientFailure: { + code: EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE, + status: issue.status, + reason: failureReason, + recordedAt: new Date().toISOString(), + }, + }, + }); + } + const transientFailureStatus = executionIssueTransientFailureStatusFromPayload(run.triggerPayload) + ?? legacyExecutionIssueTransientFailureStatus(run.failureReason); + if (run.status === "failed" && transientFailureStatus) { + return finalizeRun(issue.originRunId, { + status: "issue_created", + failureReason: null, + completedAt: null, + triggerPayload: { + ...(run.triggerPayload ?? {}), + transientFailure: { + code: EXECUTION_ISSUE_TRANSIENT_FAILURE_CODE, + status: transientFailureStatus, + reason: executionIssueTransientFailureReason(transientFailureStatus), + clearedAt: new Date().toISOString(), + }, + }, }); } return null;