diff --git a/.agents/skills/release-changelog-discord-message/SKILL.md b/.agents/skills/release-changelog-discord-message/SKILL.md index 7852ad1a83c..cd8cc50af3f 100644 --- a/.agents/skills/release-changelog-discord-message/SKILL.md +++ b/.agents/skills/release-changelog-discord-message/SKILL.md @@ -163,6 +163,9 @@ Mimic this register; do not invent a "professional" tone. - **"WHATS NEXT" is forward-looking themes**, not a literal sprint list. 3–5 bullets is the right size. Pull these from active goals, in-flight projects, and recent issues the team is working on — do not invent themes. +- **Highlights follow the changelog skill's delta rule**: a feature the + previous announcement already introduced appears only for what changed + this release, and does not headline twice for follow-through work. - **"What's on my mind"** is dotta's personal/strategic thinking — docs gaps, philosophical positioning ("we're the human control plane for ai labor"), invitations ("if you've ever wanted to write about how you use Paperclip, diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md index da1d141d336..55e7b360ea3 100644 --- a/.agents/skills/release-changelog/SKILL.md +++ b/.agents/skills/release-changelog/SKILL.md @@ -168,6 +168,14 @@ Guidelines: - write from the user perspective - keep highlights short and concrete - spell out upgrade actions for breaking changes +- **describe deltas, not repeats**: read the previous stable's notes + (`releases/v.md`) before writing. When they already + introduced a feature, this release's entry covers only what changed — + a default flip, a hardening, a completion — phrased against the prior + release ("last release introduced X; this release makes it the + default"), never re-describing the feature as if it debuted. A theme + that headlined the previous release does not headline again for + follow-through work; demote it to Improvements. ### Inline PR and contributor attribution 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: "/" 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/cli/src/__tests__/secrets.test.ts b/cli/src/__tests__/secrets.test.ts index 295a8e5e036..8ae2a77ff5c 100644 --- a/cli/src/__tests__/secrets.test.ts +++ b/cli/src/__tests__/secrets.test.ts @@ -139,6 +139,9 @@ describe("secrets CLI helpers", () => { delete process.env.AWS_DEFAULT_REGION; delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID; delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID; + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + delete process.env.AWS_SESSION_TOKEN; }); afterEach(() => { diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index dab7f78bfd9..1de56bb9bee 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -8,30 +8,43 @@ import { eq } from "drizzle-orm"; import { afterEach, describe, expect, it, vi } from "vitest"; import { agents, + authAccounts, authUsers, companies, + companyMemberships, createDb, + executionWorkspaces, + inspectMigrations, issueComments, issues, + projectWorkspaces, + instanceUserRoles, projects, routines, routineTriggers, + workspaceRuntimeServices, } from "@paperclipai/db"; import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, + ensureEmbeddedPostgres, ensureWorktreeSeeded, + formatWorktreeSeedFailureDiagnostic, + inspectLegacyWorktreeDatabase, markWorktreeSeedPending, pauseSeededScheduledRoutines, quarantineSeededWorktreeExecutionState, + readWorktreeSeedManifest, readSourceAttachmentBody, rebindWorkspaceCwd, + requiresWorktreeSeedCredentialAccount, resolveSourceConfigPath, resolveWorktreeReseedSource, resolveWorktreeReseedTargetPaths, resolveGitWorktreeAddArgs, resolvePnpmInstallInvocation, resolveCurrentWorktreeEndpoint, + resolveWorktreeSeedMigrationRevision, resolveWorktreeSeedBackupEngine, resolveWorktreeMakeTargetPath, worktreeRepairCommand, @@ -61,6 +74,94 @@ const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip; const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +function mockVerifiedSeedResult() { + return { + backupSummary: "snapshot.sql", + snapshotAt: "2026-08-18T00:00:00.000Z", + migrationRevision: "0142_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: "0142_test.sql", + }, + }; +} + +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: userId, + email: userId === "local-board" ? "local@paperclip.local" : "existing@paperclip.ing", + name: userId === "local-board" ? "Board" : "Existing User", + emailVerified: true, + 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, + role: "instance_admin", + }); + await db.insert(companies).values({ + id: companyId, + name: "Seed Source", + issuePrefix: "SEED", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companyMemberships).values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + }); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Representative seed issue", + status: "backlog", + priority: "medium", + issueNumber: 1, + identifier: "SEED-1", + }); + await db.$client.end({ timeout: 5 }); + return { companyId, issueId }; +} + if (!embeddedPostgresSupport.supported) { console.warn( `Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, @@ -397,7 +498,134 @@ describe("worktree helpers", () => { expect(full.nullifyColumns).toEqual({}); }); - it("ensure-seeded seeds once and fast-exits on the seed-complete marker", async () => { + 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("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", + tableCount: 1, + availableMigrations: ["0001_initial.sql", "0002_current.sql"], + appliedMigrations: ["0001_initial.sql", "0003_unknown.sql"], + journalEntryCount: 3, + }, "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", () => { + expect(resolveWorktreeSeedMigrationRevision({ + status: "needsMigrations", + tableCount: 1, + availableMigrations: [ + "0001_initial.sql", + "0002_applied.sql", + "0003_pending.sql", + "0004_pending.sql", + ], + appliedMigrations: ["0002_applied.sql", "0001_initial.sql"], + pendingMigrations: ["0003_pending.sql", "0004_pending.sql"], + journalEntryCount: 3, + reason: "pending-migrations", + }, "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 { const sourceConfigPath = path.join(tempRoot, "source", "config.json"); @@ -418,6 +646,7 @@ describe("worktree helpers", () => { 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"), @@ -426,7 +655,7 @@ describe("worktree helpers", () => { markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); const seedDatabase = vi.fn().mockResolvedValue({ - backupSummary: "snapshot.sql", + ...mockVerifiedSeedResult(), pausedScheduledRoutines: 2, executionQuarantine: { disabledTimerHeartbeats: 1, @@ -434,16 +663,18 @@ describe("worktree helpers", () => { quarantinedInProgressIssues: 1, unassignedTodoIssues: 1, unassignedReviewIssues: 1, + stoppedProjectWorkspaceRuntimes: 0, + stoppedExecutionWorkspaceRuntimes: 0, + stoppedRuntimeServices: 0, }, - reboundWorkspaces: [], }); await expect( - ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), + ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }), ).resolves.toMatchObject({ seeded: true, reason: "seeded" }); await expect( ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), - ).resolves.toEqual({ seeded: false, reason: "complete_marker" }); + ).resolves.toEqual({ seeded: false, reason: "verified_manifest" }); expect(seedDatabase).toHaveBeenCalledTimes(1); expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({ @@ -452,13 +683,286 @@ describe("worktree helpers", () => { instanceId: "ensure-seeded-test", })); expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(false); - expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(true); + expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(false); + expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({ + version: 2, + state: "verified", + phase: "complete", + migrationRevision: "0142_test.sql", + targetInstanceId: "ensure-seeded-test", + }); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } }); - it("ensure-seeded keeps the pending marker when seeding fails", async () => { + 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 { + const baseRoot = path.join(tempRoot, "base"); + const sourceConfigPath = path.join(baseRoot, ".paperclip", "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: "managed-target", + }); + const sourceConfig = buildSourceConfig(); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths: targetPaths, + serverPort: 3195, + databasePort: 54995, + }); + 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=managed-source\n"); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`); + fs.writeFileSync( + path.join(path.dirname(targetConfigPath), ".env"), + `PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=managed-target\n`, + ); + markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); + const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult()); + + await expect(ensureWorktreeSeeded({ + config: targetConfigPath, + registeredBaseWorkspaceCwd: baseRoot, + registeredProjectWorkspaceId: "project-workspace-1", + expectedCompanyId: "company-1", + }, { seedDatabase })).resolves.toMatchObject({ seeded: true, reason: "seeded" }); + + expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({ + sourceConfigPath, + expectedCompanyId: "company-1", + })); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)( + "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 { + const baseRoot = path.join(tempRoot, "base"); + const canonicalSource = path.join(baseRoot, ".paperclip", "config.json"); + const targetRoot = path.join(tempRoot, "worktree"); + const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json"); + const attackerRoot = path.join(tempRoot, variant); + const attackerConfig = path.join(attackerRoot, "config.json"); + fs.mkdirSync(path.dirname(canonicalSource), { recursive: true }); + fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true }); + fs.mkdirSync(attackerRoot, { recursive: true }); + fs.writeFileSync(canonicalSource, `${JSON.stringify(buildSourceConfig())}\n`); + fs.writeFileSync(path.join(path.dirname(canonicalSource), ".env"), "PAPERCLIP_INSTANCE_ID=registered-source\n"); + fs.writeFileSync(targetConfigPath, `${JSON.stringify(buildSourceConfig())}\n`); + fs.writeFileSync( + path.join(path.dirname(targetConfigPath), ".env"), + `PAPERCLIP_HOME=${path.join(tempRoot, "worktree-home")}\nPAPERCLIP_INSTANCE_ID=managed-target\n`, + ); + fs.writeFileSync(attackerConfig, `${JSON.stringify(buildSourceConfig())}\n`); + fs.writeFileSync( + path.join(attackerRoot, ".env"), + `PAPERCLIP_INSTANCE_ID=${variant === "foreign_instance" ? "foreign" : "registered-source"}\n`, + ); + const diagnosticPath = variant === "instance_mismatch" + ? canonicalSource + : variant === "symlink" + ? path.join(attackerRoot, "source-link.json") + : attackerConfig; + if (variant === "symlink") fs.symlinkSync(canonicalSource, diagnosticPath); + markWorktreeSeedPending({ + configPath: targetConfigPath, + sourceConfigPath: diagnosticPath, + targetInstanceId: "managed-target", + }); + if (variant === "instance_mismatch") { + const manifest = readWorktreeSeedManifest(targetConfigPath)!; + fs.writeFileSync( + path.join(path.dirname(targetConfigPath), "seed-manifest.json"), + JSON.stringify({ ...manifest, source: { ...manifest.source, instanceId: "foreign" } }), + ); + } + const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult()); + + await expect(ensureWorktreeSeeded({ + config: targetConfigPath, + registeredBaseWorkspaceCwd: baseRoot, + registeredProjectWorkspaceId: "project-workspace-1", + expectedCompanyId: "company-1", + }, { seedDatabase })).resolves.toMatchObject({ seeded: true, reason: "seeded" }); + + 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 }); + } + }, + ); + + 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"); @@ -479,6 +983,7 @@ describe("worktree helpers", () => { 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"), @@ -488,12 +993,31 @@ describe("worktree helpers", () => { await expect( ensureWorktreeSeeded( - { config: targetConfigPath }, - { seedDatabase: vi.fn().mockRejectedValue(new Error("seed failed")) }, + { config: targetConfigPath, fromConfig: sourceConfigPath }, + { + 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(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(true); + expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({ + state: "failed", + 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); expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false); } finally { @@ -522,6 +1046,7 @@ describe("worktree helpers", () => { 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"), @@ -531,28 +1056,17 @@ describe("worktree helpers", () => { const seedDatabase = vi.fn(async () => { await new Promise((resolve) => setTimeout(resolve, 100)); - return { - backupSummary: "snapshot.sql", - pausedScheduledRoutines: 0, - executionQuarantine: { - disabledTimerHeartbeats: 0, - resetRunningAgents: 0, - quarantinedInProgressIssues: 0, - unassignedTodoIssues: 0, - unassignedReviewIssues: 0, - }, - reboundWorkspaces: [], - }; + return mockVerifiedSeedResult(); }); const results = await Promise.all([ - ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), - ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }), + ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }), + ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }), ]); expect(results).toEqual(expect.arrayContaining([ expect.objectContaining({ seeded: true, reason: "seeded" }), - { seeded: false, reason: "complete_marker" }, + { seeded: false, reason: "verified_manifest" }, ])); expect(seedDatabase).toHaveBeenCalledTimes(1); expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false); @@ -561,6 +1075,59 @@ describe("worktree helpers", () => { } }); + it("records an interrupted phase before retrying to a verified terminal state", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-interrupted-seed-")); + 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: "interrupted-seed", + }); + const sourceConfig = buildSourceConfig(); + const targetConfig = buildWorktreeConfig({ + sourceConfig, + paths: targetPaths, + serverPort: 3196, + databasePort: 54996, + }); + 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`, + ); + markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath }); + const interrupted = readWorktreeSeedManifest(targetConfigPath)!; + fs.writeFileSync( + path.join(targetRoot, ".paperclip", "seed-manifest.json"), + `${JSON.stringify({ ...interrupted, state: "running", phase: "restore" }, null, 2)}\n`, + ); + + await expect(ensureWorktreeSeeded( + { config: targetConfigPath, fromConfig: sourceConfigPath }, + { seedDatabase: vi.fn().mockResolvedValue(mockVerifiedSeedResult()) }, + )).resolves.toMatchObject({ seeded: true, reason: "seeded" }); + + const verified = readWorktreeSeedManifest(targetConfigPath)!; + expect(verified.state).toBe("verified"); + expect(verified.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + phase: "restore", + status: "failed", + message: "The previous seed attempt ended without a terminal result.", + }), + ])); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("fails closed instead of racing to reclaim a stale seed lock", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-stale-lock-")); try { @@ -599,6 +1166,10 @@ describe("worktree helpers", () => { const todoIssueId = randomUUID(); const reviewIssueId = randomUUID(); const userIssueId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const runtimeServiceId = randomUUID(); try { await db.insert(companies).values({ @@ -634,6 +1205,64 @@ describe("worktree helpers", () => { permissions: {}, }, ]); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime quarantine", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + cwd: "/source/project", + metadata: { + keep: "project-metadata", + runtimeConfig: { + workspaceRuntime: { services: [{ name: "paperclip-dev" }] }, + desiredState: "running", + serviceStates: { "0": "running", "1": "manual" }, + }, + }, + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Copied runtime workspace", + cwd: "/source/worktree", + providerType: "git_worktree", + metadata: { + keep: "execution-metadata", + config: { + environmentId: "environment-1", + desiredState: "running", + serviceStates: { "0": "running" }, + }, + }, + }); + await db.insert(workspaceRuntimeServices).values({ + id: runtimeServiceId, + companyId, + projectId, + projectWorkspaceId, + executionWorkspaceId, + scopeType: "project_workspace", + scopeId: projectWorkspaceId, + serviceName: "paperclip-dev", + status: "running", + lifecycle: "shared", + provider: "local_process", + providerRef: "12345", + ownerAgentId: agentId, + port: 42013, + url: "https://paperclip-dev.example.test:42013", + healthStatus: "healthy", + }); await db.insert(issues).values([ { id: inProgressIssueId, @@ -685,6 +1314,9 @@ describe("worktree helpers", () => { quarantinedInProgressIssues: 1, unassignedTodoIssues: 1, unassignedReviewIssues: 1, + stoppedProjectWorkspaceRuntimes: 1, + stoppedExecutionWorkspaceRuntimes: 1, + stoppedRuntimeServices: 1, }); const [quarantinedAgent] = await db.select().from(agents).where(eq(agents.id, agentId)); @@ -715,6 +1347,47 @@ describe("worktree helpers", () => { const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, inProgressIssueId)); expect(comments).toHaveLength(1); expect(comments[0]?.body).toContain("Quarantined during worktree seed"); + + const [projectWorkspace] = await db + .select() + .from(projectWorkspaces) + .where(eq(projectWorkspaces.id, projectWorkspaceId)); + expect(projectWorkspace?.metadata).toEqual({ + keep: "project-metadata", + runtimeConfig: { + workspaceRuntime: { services: [{ name: "paperclip-dev" }] }, + desiredState: "stopped", + serviceStates: { "0": "stopped", "1": "manual" }, + }, + }); + + const [executionWorkspace] = await db + .select() + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, executionWorkspaceId)); + expect(executionWorkspace?.metadata).toEqual({ + keep: "execution-metadata", + config: { + environmentId: "environment-1", + desiredState: "stopped", + serviceStates: { "0": "stopped" }, + }, + }); + + const [runtimeService] = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, runtimeServiceId)); + expect(runtimeService).toMatchObject({ + status: "stopped", + healthStatus: "unknown", + providerRef: null, + ownerAgentId: null, + startedByRunId: null, + port: null, + url: null, + }); + expect(runtimeService?.stoppedAt).toBeInstanceOf(Date); } finally { await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); await tempDb.cleanup(); @@ -854,7 +1527,94 @@ describe("worktree helpers", () => { }); itEmbeddedPostgres( - "seeds authenticated users into minimally cloned worktree instances", + "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 () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-")); const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed"); @@ -868,15 +1628,57 @@ describe("worktree helpers", () => { const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-"); try { + await seedValidWorktreeSource(sourceDb.connectionString); const sourceDbClient = createDb(sourceDb.connectionString); - await sourceDbClient.insert(authUsers).values({ - id: "user-existing", - email: "existing@paperclip.ing", - name: "Existing User", - emailVerified: true, - createdAt: new Date(), - updatedAt: new Date(), - }); + await sourceDbClient.$client.unsafe(` + 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); + expect(laggingMigrationState.status).toBe("needsMigrations"); + if (laggingMigrationState.status !== "needsMigrations") { + throw new Error("Expected the source migration journal to lag the code journal"); + } + expect(laggingMigrationState.pendingMigrations).toHaveLength(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 }); fs.mkdirSync(worktreeRoot, { recursive: true }); @@ -913,6 +1715,19 @@ describe("worktree helpers", () => { const targetConfig = JSON.parse( fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"), ) as PaperclipConfig; + const manifestText = fs.readFileSync( + path.join(worktreeRoot, ".paperclip", "seed-manifest.json"), + "utf8", + ); + expect(JSON.parse(manifestText)).toMatchObject({ + version: 2, + seedMode: "minimal", + state: "verified", + phase: "complete", + }); + expect(manifestText).toContain(`Validated migration ${sourceMigrationRevision}`); + expect(manifestText).not.toContain("fixture-password-hash"); + expect(manifestText).not.toContain("source-master-key"); const { default: EmbeddedPostgres } = await import("embedded-postgres"); const targetPg = new EmbeddedPostgres({ databaseDir: targetConfig.database.embeddedPostgresDataDir, @@ -1025,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"); @@ -1169,9 +2039,8 @@ describe("worktree helpers", () => { const originalCwd = process.cwd(); const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; const currentDatabaseReservation = await reserveTestPort(); - const sourceDatabaseReservation = await reserveTestPort(); const currentDatabasePort = currentDatabaseReservation.port; - const sourceDatabasePort = sourceDatabaseReservation.port; + const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-reseed-source-"); try { fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true }); @@ -1186,15 +2055,28 @@ describe("worktree helpers", () => { serverPort: 3114, databasePort: currentDatabasePort, }); - const sourceConfig = buildWorktreeConfig({ - sourceConfig: buildSourceConfig(), - paths: sourcePaths, - serverPort: 3200, - databasePort: sourceDatabasePort, - }); + const sourceConfig = buildSourceConfig(); + sourceConfig.database = { + mode: "postgres", + embeddedPostgresDataDir: sourcePaths.embeddedPostgresDataDir, + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: sourcePaths.backupDir, + }, + connectionString: sourceDb.connectionString, + }; + sourceConfig.logging.logDir = sourcePaths.logDir; + sourceConfig.storage.localDisk.baseDir = sourcePaths.storageDir; + sourceConfig.secrets.localEncrypted.keyFilePath = sourcePaths.secretsKeyFilePath; + await seedValidWorktreeSource(sourceDb.connectionString); fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8"); fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8"); fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8"); + const worktreeSentinelPath = path.join(repoRoot, "user-worktree-file.txt"); + fs.writeFileSync(worktreeSentinelPath, "preserve me", "utf8"); fs.writeFileSync( currentPaths.envPath, [ @@ -1210,11 +2092,11 @@ describe("worktree helpers", () => { process.chdir(repoRoot); await currentDatabaseReservation.release(); - await sourceDatabaseReservation.release(); await worktreeReseedCommand({ fromConfig: sourcePaths.configPath, yes: true, + backupTarget: true, }); const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8")); @@ -1226,9 +2108,13 @@ describe("worktree helpers", () => { expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`); expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name"); expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\""); + expect(fs.readFileSync(worktreeSentinelPath, "utf8")).toBe("preserve me"); + expect( + fs.readdirSync(path.join(currentPaths.backupDir, "repair")).some((name) => name.endsWith(".sql.gz")), + ).toBe(true); } finally { await currentDatabaseReservation.release(); - await sourceDatabaseReservation.release(); + await sourceDb.cleanup(); process.chdir(originalCwd); if (originalPaperclipConfig === undefined) { delete process.env.PAPERCLIP_CONFIG; diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 7da2857c5d6..4cdf7ef5364 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -5,12 +5,52 @@ import { expandHomePrefix } from "../config/home.js"; export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees"; export const WORKTREE_SEED_MODES = ["minimal", "full"] as const; +export const WORKTREE_SEED_MANIFEST = "seed-manifest.json"; export const WORKTREE_SEED_PENDING_MARKER = "seed-pending"; export const WORKTREE_SEED_COMPLETE_MARKER = "seed-complete"; export const WORKTREE_SEED_LOCK_MARKER = "seed.lock"; export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number]; +export const WORKTREE_SEED_PHASES = [ + "pending", + "source_validation", + "snapshot", + "restore", + "migrations", + "execution_quarantine", + "routine_pause", + "workspace_rebind", + "post_restore_validation", + "complete", +] as const; + +export type WorktreeSeedPhase = (typeof WORKTREE_SEED_PHASES)[number]; +export type WorktreeSeedState = "pending" | "running" | "verified" | "failed"; + +export type WorktreeSeedManifest = { + version: 2; + source: { + instanceId: string; + configPath: string; + }; + snapshotAt: string | null; + seedMode: WorktreeSeedMode; + migrationRevision: string | null; + targetInstanceId: string; + phase: WorktreeSeedPhase; + state: WorktreeSeedState; + attemptId: string; + startedAt: string | null; + finishedAt: string | null; + diagnostics: Array<{ + phase: WorktreeSeedPhase; + status: "started" | "succeeded" | "failed"; + at: string; + message?: string; + }>; +}; + export type WorktreeSeedPlan = { mode: WorktreeSeedMode; excludedTables: string[]; @@ -54,6 +94,7 @@ export type WorktreeUiBranding = { }; export type WorktreeSeedMarkerPaths = { + manifest: string; pending: string; complete: string; lock: string; @@ -62,6 +103,7 @@ export type WorktreeSeedMarkerPaths = { export function resolveWorktreeSeedMarkerPaths(configPath: string): WorktreeSeedMarkerPaths { const configDir = path.dirname(path.resolve(configPath)); return { + manifest: path.resolve(configDir, WORKTREE_SEED_MANIFEST), pending: path.resolve(configDir, WORKTREE_SEED_PENDING_MARKER), complete: path.resolve(configDir, WORKTREE_SEED_COMPLETE_MARKER), lock: path.resolve(configDir, WORKTREE_SEED_LOCK_MARKER), diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index fe07f3ce618..6b7c76ed70f 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -7,6 +7,7 @@ import { readdirSync, readFileSync, readlinkSync, + renameSync, rmSync, statSync, symlinkSync, @@ -21,15 +22,28 @@ 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, + resolveRegisteredWorktreeSeedSource, +} from "@paperclipai/shared/worktree-seed-source"; +import { + readWorktreePortRegistry, + withWorktreePortRegistryLock, + writeWorktreePortRegistry, +} from "@paperclipai/shared/worktree-port-registry"; import { applyPendingMigrations, agents, + authAccounts, + authUsers, assets, companies, + companyMemberships, createDb, documentRevisions, documents, ensurePostgresDatabase, + executionWorkspaces, formatDatabaseBackupResult, goals, heartbeatRuns, @@ -38,6 +52,7 @@ import { issueComments, issueDocuments, issues, + instanceUserRoles, projectWorkspaces, projects, routines, @@ -45,8 +60,10 @@ import { runDatabaseBackup, runDatabaseRestore, resetPostgresDatabase, + workspaceRuntimeServices, createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError, + loadWithoutEmbeddedPostgresExitHooks, prepareEmbeddedPostgresNativeRuntime, } from "@paperclipai/db"; import type { Command } from "commander"; @@ -63,13 +80,16 @@ import { formatShellExports, generateWorktreeColor, isWorktreeSeedMode, + WORKTREE_SEED_PHASES, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, resolveWorktreeSeedMarkerPaths, resolveWorktreeLocalPaths, sanitizeWorktreeInstanceId, type WorktreeSeedPlan, + type WorktreeSeedManifest, type WorktreeSeedMode, + type WorktreeSeedPhase, type WorktreeLocalPaths, } from "./worktree-lib.js"; import { @@ -135,6 +155,7 @@ type WorktreeReseedOptions = { preserveLiveWork?: boolean; yes?: boolean; allowLiveTarget?: boolean; + backupTarget?: boolean; }; type WorktreeRepairOptions = { @@ -155,6 +176,9 @@ type WorktreeEnsureSeededOptions = { fromDataDir?: string; fromInstance?: string; preserveLiveWork?: boolean; + registeredBaseWorkspaceCwd?: string; + registeredProjectWorkspaceId?: string; + expectedCompanyId?: string; }; type EmbeddedPostgresInstance = { @@ -195,6 +219,8 @@ type CopiedGitHooksResult = { type SeedWorktreeDatabaseResult = { backupSummary: string; + snapshotAt: string; + migrationRevision: string; pausedScheduledRoutines: number; executionQuarantine: SeededWorktreeExecutionQuarantineSummary; reboundWorkspaces: Array<{ @@ -202,37 +228,47 @@ type SeedWorktreeDatabaseResult = { fromCwd: string; toCwd: string; }>; + validation: WorktreeSeedValidationSummary; }; -type WorktreeSeedPendingMarker = { - version: 1; - state: "pending"; - sourceConfigPath: string; - seedMode: "minimal"; - createdAt: string; -}; - -type WorktreeSeedCompleteMarker = { - version: 1; - state: "complete"; - seedMode: WorktreeSeedMode; - completedAt: string; +export type WorktreeSeedValidationSummary = { + authUserCount: number; + credentialAccountCount: number; + instanceAdminCount: number; + activeMembershipCount: number; + companyCount: number; + issueCount: number; + representativeCompanyId: string; + representativeIssueId: string; + migrationRevision: string; }; type SeedWorktreeDatabase = typeof seedWorktreeDatabase; export type EnsureWorktreeSeededResult = { seeded: boolean; - reason: "seeded" | "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; quarantinedInProgressIssues: number; unassignedTodoIssues: number; unassignedReviewIssues: number; + stoppedProjectWorkspaceRuntimes: number; + stoppedExecutionWorkspaceRuntimes: number; + stoppedRuntimeServices: number; }; function nonEmpty(value: string | null | undefined): string | null { @@ -256,6 +292,9 @@ function formatSeededWorktreeExecutionQuarantineSummary( `quarantined in-progress issues: ${summary.quarantinedInProgressIssues}`, `unassigned todo issues: ${summary.unassignedTodoIssues}`, `unassigned review issues: ${summary.unassignedReviewIssues}`, + `stopped project workspace runtimes: ${summary.stoppedProjectWorkspaceRuntimes}`, + `stopped execution workspace runtimes: ${summary.stoppedExecutionWorkspaceRuntimes}`, + `stopped runtime services: ${summary.stoppedRuntimeServices}`, ].join(", "); } @@ -562,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 })) { @@ -598,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. @@ -1084,11 +1139,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( @@ -1100,6 +1159,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, @@ -1187,6 +1252,9 @@ const EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY: SeededWorktreeExecutio quarantinedInProgressIssues: 0, unassignedTodoIssues: 0, unassignedReviewIssues: 0, + stoppedProjectWorkspaceRuntimes: 0, + stoppedExecutionWorkspaceRuntimes: 0, + stoppedRuntimeServices: 0, }; function isRecord(value: unknown): value is Record { @@ -1218,6 +1286,36 @@ function normalizeWorktreeRuntimeConfig(runtimeConfig: unknown): { return { runtimeConfig: nextRuntimeConfig, disabledTimerHeartbeat: false, changed: false }; } +function stopSeededWorkspaceRuntime( + metadata: unknown, + configKey: "config" | "runtimeConfig", +): { metadata: Record; changed: boolean } { + const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; + const currentConfig = isRecord(nextMetadata[configKey]) + ? { ...(nextMetadata[configKey] as Record) } + : null; + if (!currentConfig) return { metadata: nextMetadata, changed: false }; + + let changed = false; + if (currentConfig.desiredState === "running") { + currentConfig.desiredState = "stopped"; + changed = true; + } + + if (isRecord(currentConfig.serviceStates)) { + const nextServiceStates = { ...currentConfig.serviceStates }; + for (const [serviceIndex, state] of Object.entries(nextServiceStates)) { + if (state !== "running") continue; + nextServiceStates[serviceIndex] = "stopped"; + changed = true; + } + if (changed) currentConfig.serviceStates = nextServiceStates; + } + + if (changed) nextMetadata[configKey] = currentConfig; + return { metadata: nextMetadata, changed }; +} + export async function quarantineSeededWorktreeExecutionState( connectionString: string, ): Promise { @@ -1300,6 +1398,50 @@ export async function quarantineSeededWorktreeExecutionState( summary.unassignedReviewIssues += 1; } } + + const seededProjectWorkspaces = await tx + .select({ id: projectWorkspaces.id, metadata: projectWorkspaces.metadata }) + .from(projectWorkspaces); + for (const workspace of seededProjectWorkspaces) { + const stopped = stopSeededWorkspaceRuntime(workspace.metadata, "runtimeConfig"); + if (!stopped.changed) continue; + await tx + .update(projectWorkspaces) + .set({ metadata: stopped.metadata, updatedAt: new Date() }) + .where(eq(projectWorkspaces.id, workspace.id)); + summary.stoppedProjectWorkspaceRuntimes += 1; + } + + const seededExecutionWorkspaces = await tx + .select({ id: executionWorkspaces.id, metadata: executionWorkspaces.metadata }) + .from(executionWorkspaces); + for (const workspace of seededExecutionWorkspaces) { + const stopped = stopSeededWorkspaceRuntime(workspace.metadata, "config"); + if (!stopped.changed) continue; + await tx + .update(executionWorkspaces) + .set({ metadata: stopped.metadata, updatedAt: new Date() }) + .where(eq(executionWorkspaces.id, workspace.id)); + summary.stoppedExecutionWorkspaceRuntimes += 1; + } + + const now = new Date(); + const stoppedRuntimeServices = await tx + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + providerRef: null, + ownerAgentId: null, + startedByRunId: null, + port: null, + url: null, + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .returning({ id: workspaceRuntimeServices.id }); + summary.stoppedRuntimeServices = stoppedRuntimeServices.length; }); return summary; @@ -1308,6 +1450,233 @@ export async function quarantineSeededWorktreeExecutionState( } } +type WorktreeSeedValidationExpectation = { + adminUserId: string; + representativeCompanyId: string; + representativeIssueId: string; +}; + +export function requiresWorktreeSeedCredentialAccount( + deploymentMode: PaperclipConfig["server"]["deploymentMode"], +): boolean { + return deploymentMode === "authenticated"; +} + +export function resolveWorktreeSeedMigrationRevision( + migrationState: Awaited>, + requirement: "sourcePrefix" | "upToDate", +): string { + const expectedAppliedPrefix = migrationState.availableMigrations.slice( + 0, + migrationState.appliedMigrations.length, + ); + const appliedMigrationNames = new Set(migrationState.appliedMigrations); + if ( + 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."); + } + + if (requirement === "upToDate" && migrationState.status !== "upToDate") { + throw new Error( + `Migration journal is not current (${migrationState.pendingMigrations.length} pending migration(s)).`, + ); + } + + const migrationRevision = expectedAppliedPrefix.at(-1); + if (!migrationRevision) { + throw new Error("Migration journal has no applied revision."); + } + 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, + 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, + migrationRequirement, + ); + + const db = createDb(connectionString); + try { + const [counts] = await db + .select({ + authUserCount: sql`count(distinct ${authUsers.id})::int`, + credentialAccountCount: sql`count(distinct ${authAccounts.id})::int`, + instanceAdminCount: sql`count(distinct ${instanceUserRoles.userId})::int`, + activeMembershipCount: sql`count(distinct ${companyMemberships.id})::int`, + companyCount: sql`count(distinct ${companies.id})::int`, + issueCount: sql`count(distinct ${issues.id})::int`, + }) + .from(authUsers) + .leftJoin(authAccounts, eq(authAccounts.userId, authUsers.id)) + .leftJoin( + instanceUserRoles, + and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")), + ) + .leftJoin( + companyMemberships, + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, authUsers.id), + eq(companyMemberships.status, "active"), + ), + ) + .leftJoin(companies, eq(companies.id, companyMemberships.companyId)) + .leftJoin(issues, eq(issues.companyId, companies.id)); + + const admin = await db + .select({ userId: authUsers.id }) + .from(authUsers) + .innerJoin( + instanceUserRoles, + and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")), + ) + .leftJoin( + authAccounts, + eq(authAccounts.userId, authUsers.id), + ) + .innerJoin( + companyMemberships, + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, authUsers.id), + eq(companyMemberships.status, "active"), + ), + ) + .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( + 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.", + ); + } + + const representative = await db + .select({ companyId: companies.id, issueId: issues.id }) + .from(companies) + .innerJoin(issues, eq(issues.companyId, companies.id)) + .where( + and( + expected ? eq(companies.id, expected.representativeCompanyId) : undefined, + expected ? eq(issues.id, expected.representativeIssueId) : undefined, + requiredCompanyId ? eq(companies.id, requiredCompanyId) : undefined, + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!representative) { + throw new Error("No representative cloned company and issue pair is readable."); + } + + const summary: WorktreeSeedValidationSummary = { + authUserCount: counts?.authUserCount ?? 0, + credentialAccountCount: counts?.credentialAccountCount ?? 0, + instanceAdminCount: counts?.instanceAdminCount ?? 0, + activeMembershipCount: counts?.activeMembershipCount ?? 0, + companyCount: counts?.companyCount ?? 0, + issueCount: counts?.issueCount ?? 0, + representativeCompanyId: representative.companyId, + representativeIssueId: representative.issueId, + migrationRevision, + }; + if ( + summary.authUserCount < 1 + || (requiresCredentialAccount && summary.credentialAccountCount < 1) + || summary.instanceAdminCount < 1 + || summary.activeMembershipCount < 1 + || summary.companyCount < 1 + || summary.issueCount < 1 + ) { + throw new Error("Seed validation found an incomplete auth, membership, company, or issue shape."); + } + + return { + summary, + expectation: { + adminUserId: admin.userId, + representativeCompanyId: representative.companyId, + representativeIssueId: representative.issueId, + }, + }; + } finally { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + async function seedWorktreeDatabase(input: { sourceConfigPath: string; sourceConfig: PaperclipConfig; @@ -1316,16 +1685,12 @@ async function seedWorktreeDatabase(input: { instanceId: string; seedMode: WorktreeSeedMode; preserveLiveWork?: boolean; + expectedCompanyId?: string; + onPhase?: (phase: WorktreeSeedPhase, status: "started" | "succeeded", message?: string) => void; }): Promise { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); - copySeededSecretsKey({ - sourceConfigPath: input.sourceConfigPath, - sourceConfig: input.sourceConfig, - sourceEnvEntries, - targetKeyFilePath: input.targetPaths.secretsKeyFilePath, - }); let sourceHandle: EmbeddedPostgresHandle | null = null; let targetHandle: EmbeddedPostgresHandle | null = null; @@ -1343,6 +1708,29 @@ async function seedWorktreeDatabase(input: { sourceEnvEntries, sourceHandle?.port, ); + input.onPhase?.("source_validation", "started"); + const sourceValidation = await inspectVerifiedSeedDatabase( + sourceConnectionString, + { + deploymentMode: input.sourceConfig.server.deploymentMode, + migrationRequirement: "sourcePrefix", + requiredCompanyId: input.expectedCompanyId, + }, + ); + input.onPhase?.( + "source_validation", + "succeeded", + `Validated migration ${sourceValidation.summary.migrationRevision}, ${sourceValidation.summary.companyCount} company record(s), and ${sourceValidation.summary.issueCount} issue record(s).`, + ); + copySeededSecretsKey({ + sourceConfigPath: input.sourceConfigPath, + sourceConfig: input.sourceConfig, + sourceEnvEntries, + targetKeyFilePath: input.targetPaths.secretsKeyFilePath, + }); + + const snapshotAt = new Date().toISOString(); + input.onPhase?.("snapshot", "started"); const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), @@ -1353,10 +1741,13 @@ async function seedWorktreeDatabase(input: { excludeTables: seedPlan.excludedTables, nullifyColumns: seedPlan.nullifyColumns, }); + 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`; @@ -1366,21 +1757,52 @@ async function seedWorktreeDatabase(input: { connectionString: targetConnectionString, backupFile: backup.backupFile, }); + input.onPhase?.("restore", "succeeded"); + input.onPhase?.("migrations", "started"); await applyPendingMigrations(targetConnectionString); + input.onPhase?.("migrations", "succeeded"); + input.onPhase?.("execution_quarantine", "started"); const executionQuarantine = input.preserveLiveWork ? { ...EMPTY_SEEDED_WORKTREE_EXECUTION_QUARANTINE_SUMMARY } : await quarantineSeededWorktreeExecutionState(targetConnectionString); + input.onPhase?.( + "execution_quarantine", + "succeeded", + input.preserveLiveWork + ? "Preserved copied live work by explicit request." + : formatSeededWorktreeExecutionQuarantineSummary(executionQuarantine), + ); + input.onPhase?.("routine_pause", "started"); const pausedScheduledRoutines = await pauseSeededScheduledRoutines(targetConnectionString); + input.onPhase?.("routine_pause", "succeeded", `Paused ${pausedScheduledRoutines} scheduled routine(s).`); + input.onPhase?.("workspace_rebind", "started"); const reboundWorkspaces = await rebindSeededProjectWorkspaces({ targetConnectionString, currentCwd: input.targetPaths.cwd, }); + input.onPhase?.("workspace_rebind", "succeeded", `Rebound ${reboundWorkspaces.length} workspace path(s).`); + input.onPhase?.("post_restore_validation", "started"); + const targetValidation = await inspectVerifiedSeedDatabase( + targetConnectionString, + { + deploymentMode: input.targetConfig.server.deploymentMode, + expected: sourceValidation.expectation, + }, + ); + input.onPhase?.( + "post_restore_validation", + "succeeded", + `Validated migration ${targetValidation.summary.migrationRevision}.`, + ); return { backupSummary: formatDatabaseBackupResult(backup), + snapshotAt, + migrationRevision: targetValidation.summary.migrationRevision, pausedScheduledRoutines, executionQuarantine, reboundWorkspaces, + validation: targetValidation.summary, }; } finally { if (targetHandle?.startedByThisProcess) { @@ -1392,46 +1814,216 @@ async function seedWorktreeDatabase(input: { } } -function writeWorktreeSeedMarker( - filePath: string, - marker: WorktreeSeedPendingMarker | WorktreeSeedCompleteMarker, -): void { +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."; + } + 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}.`; +} + +function dispatchSeedInterruption(signal: NodeJS.Signals): void { + for (const handler of activeSeedInterruptHandlers.values()) { + try { + handler(signal); + } catch { + // Continue terminalizing the other active manifests before exiting. + } + } + process.exit(signal === "SIGINT" ? 130 : 143); +} + +const dispatchSeedSigint = () => dispatchSeedInterruption("SIGINT"); +const dispatchSeedSigterm = () => dispatchSeedInterruption("SIGTERM"); + +function registerSeedInterruptHandler(handler: (signal: NodeJS.Signals) => void): () => void { + const id = randomUUID(); + if (activeSeedInterruptHandlers.size === 0) { + process.once("SIGINT", dispatchSeedSigint); + process.once("SIGTERM", dispatchSeedSigterm); + } + activeSeedInterruptHandlers.set(id, handler); + return () => { + activeSeedInterruptHandlers.delete(id); + if (activeSeedInterruptHandlers.size === 0) { + process.off("SIGINT", dispatchSeedSigint); + process.off("SIGTERM", dispatchSeedSigterm); + } + }; +} + +type LegacyWorktreeSeedPendingMarker = { + version: 1; + state: "pending"; + sourceConfigPath: string; +}; + +function resolveSeedInstanceId(configPath: string): string { + const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath)); + return nonEmpty(envEntries.PAPERCLIP_INSTANCE_ID) + ?? sanitizeWorktreeInstanceId(path.basename(path.dirname(path.resolve(configPath)))); +} + +function writeWorktreeSeedManifest(filePath: string, manifest: WorktreeSeedManifest): void { mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(marker, null, 2)}\n`, { mode: 0o600 }); + const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`; + writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporaryPath, filePath); +} + +export function readWorktreeSeedManifest(configPath: string): WorktreeSeedManifest | null { + const manifestPath = resolveWorktreeSeedMarkerPaths(configPath).manifest; + if (!existsSync(manifestPath)) return null; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch (error) { + throw new Error( + `Invalid worktree seed manifest at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const value = parsed as Partial; + const diagnosticsValid = Array.isArray(value.diagnostics) && value.diagnostics.every((diagnostic) => ( + diagnostic + && typeof diagnostic === "object" + && WORKTREE_SEED_PHASES.includes(diagnostic.phase) + && ["started", "succeeded", "failed"].includes(diagnostic.status) + && typeof diagnostic.at === "string" + && (diagnostic.message === undefined || typeof diagnostic.message === "string") + )); + const verifiedTerminalValid = value.state !== "verified" || ( + value.phase === "complete" + && typeof value.snapshotAt === "string" + && value.snapshotAt.length > 0 + && typeof value.migrationRevision === "string" + && value.migrationRevision.length > 0 + && typeof value.startedAt === "string" + && typeof value.finishedAt === "string" + && value.diagnostics?.some((diagnostic) => ( + diagnostic.phase === "complete" && diagnostic.status === "succeeded" + )) === true + ); + if ( + !value + || typeof value !== "object" + || value.version !== 2 + || !value.source + || typeof value.source.instanceId !== "string" + || typeof value.source.configPath !== "string" + || typeof value.targetInstanceId !== "string" + || value.targetInstanceId.length === 0 + || !isWorktreeSeedMode(String(value.seedMode ?? "")) + || !WORKTREE_SEED_PHASES.includes(value.phase as WorktreeSeedPhase) + || !["pending", "running", "verified", "failed"].includes(String(value.state ?? "")) + || typeof value.attemptId !== "string" + || value.attemptId.length === 0 + || !diagnosticsValid + || !verifiedTerminalValid + ) { + throw new Error(`Invalid worktree seed manifest at ${manifestPath}.`); + } + return value as WorktreeSeedManifest; } export function markWorktreeSeedPending(input: { configPath: string; sourceConfigPath: string; - now?: Date; -}): void { - const markers = resolveWorktreeSeedMarkerPaths(input.configPath); - rmSync(markers.complete, { force: true }); - writeWorktreeSeedMarker(markers.pending, { - version: 1, - state: "pending", - sourceConfigPath: path.resolve(input.sourceConfigPath), - seedMode: "minimal", - createdAt: (input.now ?? new Date()).toISOString(), - }); -} - -export function markWorktreeSeedComplete(input: { - configPath: string; + targetInstanceId?: string; seedMode?: WorktreeSeedMode; now?: Date; + diagnosticMessage?: string; }): void { const markers = resolveWorktreeSeedMarkerPaths(input.configPath); - writeWorktreeSeedMarker(markers.complete, { - version: 1, - state: "complete", + const at = (input.now ?? new Date()).toISOString(); + writeWorktreeSeedManifest(markers.manifest, { + version: 2, + source: { + instanceId: resolveSeedInstanceId(input.sourceConfigPath), + configPath: path.resolve(input.sourceConfigPath), + }, + snapshotAt: null, seedMode: input.seedMode ?? "minimal", - completedAt: (input.now ?? new Date()).toISOString(), + migrationRevision: null, + targetInstanceId: input.targetInstanceId ?? resolveSeedInstanceId(input.configPath), + phase: "pending", + state: "pending", + attemptId: randomUUID(), + startedAt: null, + finishedAt: null, + 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. + rmSync(markers.complete, { force: true }); rmSync(markers.pending, { force: true }); } -function readWorktreeSeedPendingMarker(filePath: string): WorktreeSeedPendingMarker { +function updateWorktreeSeedManifest(input: { + configPath: string; + phase: WorktreeSeedPhase; + status: "started" | "succeeded" | "failed"; + state?: WorktreeSeedManifest["state"]; + message?: string; + snapshotAt?: string | null; + migrationRevision?: string | null; + now?: Date; +}): WorktreeSeedManifest { + const markers = resolveWorktreeSeedMarkerPaths(input.configPath); + const current = readWorktreeSeedManifest(input.configPath); + if (!current) throw new Error(`Worktree seed manifest does not exist at ${markers.manifest}.`); + const at = (input.now ?? new Date()).toISOString(); + const nextState = input.state ?? current.state; + const diagnostic = { + phase: input.phase, + status: input.status, + at, + ...(input.message + ? { message: input.message.slice(0, WORKTREE_SEED_DIAGNOSTIC_MESSAGE_LIMIT) } + : {}), + }; + const next: WorktreeSeedManifest = { + ...current, + phase: input.phase, + state: nextState, + snapshotAt: input.snapshotAt === undefined ? current.snapshotAt : input.snapshotAt, + migrationRevision: + input.migrationRevision === undefined ? current.migrationRevision : input.migrationRevision, + startedAt: current.startedAt ?? (input.status === "started" ? at : null), + finishedAt: nextState === "verified" || nextState === "failed" ? at : null, + diagnostics: [...current.diagnostics, diagnostic].slice(-WORKTREE_SEED_DIAGNOSTIC_LIMIT), + }; + writeWorktreeSeedManifest(markers.manifest, next); + return next; +} + +function readLegacyWorktreeSeedPendingMarker(filePath: string): LegacyWorktreeSeedPendingMarker { let parsed: unknown; try { parsed = JSON.parse(readFileSync(filePath, "utf8")); @@ -1452,7 +2044,7 @@ function readWorktreeSeedPendingMarker(filePath: string): WorktreeSeedPendingMar throw new Error(`Invalid worktree seed-pending marker at ${filePath}.`); } - return parsed as WorktreeSeedPendingMarker; + return parsed as LegacyWorktreeSeedPendingMarker; } const WORKTREE_SEED_LOCK_POLL_MS = 50; @@ -1546,41 +2138,269 @@ async function acquireWorktreeSeedLock(lockPath: string): Promise<() => Promise< } } +function startWorktreeSeedAttempt(configPath: string, now = new Date()): WorktreeSeedManifest { + const markers = resolveWorktreeSeedMarkerPaths(configPath); + const current = readWorktreeSeedManifest(configPath); + if (!current) throw new Error(`Worktree seed manifest does not exist at ${markers.manifest}.`); + const at = now.toISOString(); + const next: WorktreeSeedManifest = { + ...current, + state: "running", + phase: "pending", + attemptId: randomUUID(), + snapshotAt: null, + migrationRevision: null, + startedAt: at, + finishedAt: null, + diagnostics: [ + ...current.diagnostics, + { phase: "pending" as const, status: "started" as const, at }, + ].slice(-WORKTREE_SEED_DIAGNOSTIC_LIMIT), + }; + writeWorktreeSeedManifest(markers.manifest, next); + return next; +} + +async function runVerifiedWorktreeSeed(input: { + configPath: string; + sourceConfigPath: string; + sourceConfig: PaperclipConfig; + targetConfig: PaperclipConfig; + targetPaths: WorktreeLocalPaths; + instanceId: string; + seedMode: WorktreeSeedMode; + preserveLiveWork?: boolean; + expectedCompanyId?: string; + seedDatabase: SeedWorktreeDatabase; +}): Promise { + let activePhase: WorktreeSeedPhase = "pending"; + const previous = readWorktreeSeedManifest(input.configPath); + if (previous?.state === "running") { + updateWorktreeSeedManifest({ + configPath: input.configPath, + phase: previous.phase, + status: "failed", + state: "failed", + message: "The previous seed attempt ended without a terminal result.", + }); + } + startWorktreeSeedAttempt(input.configPath); + + const unregisterInterruption = registerSeedInterruptHandler((signal) => { + updateWorktreeSeedManifest({ + configPath: input.configPath, + phase: activePhase, + status: "failed", + state: "failed", + message: `Seed interrupted by ${signal} during ${activePhase}.`, + }); + }); + + try { + const details = await input.seedDatabase({ + sourceConfigPath: input.sourceConfigPath, + sourceConfig: input.sourceConfig, + targetConfig: input.targetConfig, + targetPaths: input.targetPaths, + instanceId: input.instanceId, + seedMode: input.seedMode, + preserveLiveWork: input.preserveLiveWork, + expectedCompanyId: input.expectedCompanyId, + onPhase: (phase, status, message) => { + activePhase = phase; + updateWorktreeSeedManifest({ + configPath: input.configPath, + phase, + status, + state: "running", + message, + ...(phase === "snapshot" && status === "started" + ? { snapshotAt: new Date().toISOString() } + : {}), + }); + }, + }); + if (!details.snapshotAt || !details.migrationRevision || !details.validation) { + throw new Error("Seed implementation returned without required validation evidence."); + } + updateWorktreeSeedManifest({ + configPath: input.configPath, + phase: "complete", + status: "succeeded", + state: "verified", + snapshotAt: details.snapshotAt, + migrationRevision: details.migrationRevision, + message: + `Verified ${details.validation.companyCount} company record(s), ` + + `${details.validation.issueCount} issue record(s), auth, admin, membership, and migration state.`, + }); + return details; + } catch (error) { + updateWorktreeSeedManifest({ + configPath: input.configPath, + phase: activePhase, + status: "failed", + 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: formatWorktreeSeedFailureDiagnostic(activePhase, error), + }); + throw error; + } finally { + unregisterInterruption(); + } +} + 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); + const initialManifest = readWorktreeSeedManifest(configPath); + if (initialManifest?.state === "verified") { + return { seeded: false, reason: "verified_manifest" }; + } + if (!initialManifest && existsSync(markers.complete)) { + return { seeded: false, reason: "complete_marker" }; + } + const legacyPending = !initialManifest && existsSync(markers.pending) + ? readLegacyWorktreeSeedPendingMarker(markers.pending) + : null; + const hasExplicitSource = Boolean(opts.fromConfig || opts.fromDataDir || opts.fromInstance); + const explicitSourceConfigPath = hasExplicitSource + ? resolveSourceConfigPath({ + fromConfig: opts.fromConfig, + fromDataDir: opts.fromDataDir, + fromInstance: opts.fromInstance, + }) + : null; + 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; + const expectedCompanyId = opts.expectedCompanyId + ?? nonEmpty(process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID) + ?? nonEmpty(process.env.PAPERCLIP_COMPANY_ID) + ?? undefined; + if (!explicitSourceConfigPath && registeredBaseWorkspaceCwd && (!registeredProjectWorkspaceId || !expectedCompanyId)) { + throw new Error( + "Managed worktree seed registration is incomplete; project workspace and company bindings are required.", + ); + } + + const targetRoot = path.dirname(path.dirname(configPath)); + const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot }); + const registeredSeedSource = resolveRegisteredWorktreeSeedSource({ + registeredBaseWorkspaceCwd, + explicitSourceConfigPath, + targetConfigPath: configPath, + expectedTargetInstanceId: targetPaths.instanceId, + }); + + 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 { // These checks deliberately happen under the cross-process lock. A second // service process waits for the first seed transaction, then observes the - // complete marker instead of cloning the same database concurrently. - if (existsSync(markers.complete)) { - return { seeded: false, reason: "complete_marker" }; + // verified manifest instead of cloning the same database concurrently. + let manifest = readWorktreeSeedManifest(configPath); + if (manifest?.state === "verified") { + return { seeded: false, reason: "verified_manifest" }; } - if (!existsSync(markers.pending)) { - // 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" }; + if (!manifest && existsSync(markers.pending)) { + const currentLegacyPending = readLegacyWorktreeSeedPendingMarker(markers.pending); + if (currentLegacyPending.sourceConfigPath !== legacyPending?.sourceConfigPath) { + throw new Error("Worktree seed source diagnostics changed while waiting for the seed lock."); + } + markWorktreeSeedPending({ + 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) { + 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" }; + } - const pending = readWorktreeSeedPendingMarker(markers.pending); - const sourceConfigPath = opts.fromConfig || opts.fromDataDir || opts.fromInstance - ? resolveSourceConfigPath({ - fromConfig: opts.fromConfig, - fromDataDir: opts.fromDataDir, - fromInstance: opts.fromInstance, - }) - : path.resolve(pending.sourceConfigPath); - - if (path.resolve(sourceConfigPath) === path.resolve(configPath)) { - throw new Error( - "Source and target Paperclip configs are the same. Pass --from-config for the source instance.", - ); + 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."); + } } + 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); if (!sourceConfig) { @@ -1591,19 +2411,19 @@ export async function ensureWorktreeSeeded( throw new Error(`Target config not found at ${configPath}.`); } - const targetRoot = path.dirname(path.dirname(configPath)); - const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot }); const seedDatabase = dependencies.seedDatabase ?? seedWorktreeDatabase; - const details = await seedDatabase({ + const details = await runVerifiedWorktreeSeed({ + configPath, sourceConfigPath, sourceConfig, targetConfig, targetPaths, instanceId: targetPaths.instanceId, - seedMode: "minimal", + seedMode: manifest.seedMode, preserveLiveWork: opts.preserveLiveWork, + expectedCompanyId, + seedDatabase, }); - markWorktreeSeedComplete({ configPath }); return { seeded: true, reason: "seeded", details }; } finally { await releaseLock(); @@ -1658,25 +2478,53 @@ 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, + targetInstanceId: instanceId, + seedMode, }); const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath)); const existingAgentJwtSecret = @@ -1705,8 +2553,11 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { } const spinner = p.spinner(); spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`); + const markers = resolveWorktreeSeedMarkerPaths(paths.configPath); + const releaseSeedLock = await acquireWorktreeSeedLock(markers.lock); try { - const seeded = await seedWorktreeDatabase({ + const seeded = await runVerifiedWorktreeSeed({ + configPath: paths.configPath, sourceConfigPath, sourceConfig, targetConfig, @@ -1714,16 +2565,18 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { instanceId, seedMode, preserveLiveWork: opts.preserveLiveWork, + seedDatabase: seedWorktreeDatabase, }); seedSummary = seeded.backupSummary; seedExecutionQuarantineSummary = seeded.executionQuarantine; pausedScheduledRoutineCount = seeded.pausedScheduledRoutines; reboundWorkspaceSummary = seeded.reboundWorkspaces; - markWorktreeSeedComplete({ configPath: paths.configPath, seedMode }); spinner.stop(`Seeded isolated worktree database (${seedMode}).`); } catch (error) { spinner.stop(pc.red("Failed to seed worktree database.")); throw error; + } finally { + await releaseSeedLock(); } } @@ -1774,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( @@ -3395,6 +4244,34 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, } } +async function backupWorktreeReseedTarget(input: { + targetConfig: PaperclipConfig; + targetPaths: WorktreeLocalPaths; +}): Promise { + if (input.targetConfig.database.mode !== "embedded-postgres") { + throw new Error("Managed worktree repair requires an embedded PostgreSQL target."); + } + const targetHandle = await ensureEmbeddedPostgres( + input.targetConfig.database.embeddedPostgresDataDir, + input.targetConfig.database.embeddedPostgresPort, + ); + try { + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const result = await runDatabaseBackup({ + connectionString: `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`, + backupDir: path.resolve(input.targetPaths.backupDir, "repair"), + retention: { dailyDays: 30, weeklyWeeks: 12, monthlyMonths: 12 }, + filenamePrefix: `${input.targetPaths.instanceId}-pre-repair`, + backupEngine: "auto", + includeMigrationJournal: true, + }); + return formatDatabaseBackupResult(result); + } finally { + if (targetHandle.startedByThisProcess) await targetHandle.stop(); + } +} + async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { const seedMode = opts.seedMode ?? "full"; if (!isWorktreeSeedMode(seedMode)) { @@ -3450,8 +4327,23 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { const spinner = p.spinner(); spinner.start(`Reseeding ${targetEndpoint.label} from ${source.label} (${seedMode})...`); + const markers = resolveWorktreeSeedMarkerPaths(targetEndpoint.configPath); + mkdirSync(path.dirname(markers.lock), { recursive: true }); + const releaseSeedLock = await acquireWorktreeSeedLock(markers.lock); try { - const seeded = await seedWorktreeDatabase({ + let targetBackupSummary: string | null = null; + if (opts.backupTarget) { + targetBackupSummary = await backupWorktreeReseedTarget({ targetConfig, targetPaths }); + p.log.message(pc.dim(`Recoverable pre-repair backup: ${targetBackupSummary}`)); + } + markWorktreeSeedPending({ + configPath: targetEndpoint.configPath, + sourceConfigPath: source.configPath, + targetInstanceId: targetPaths.instanceId, + seedMode, + }); + const seeded = await runVerifiedWorktreeSeed({ + configPath: targetEndpoint.configPath, sourceConfigPath: source.configPath, sourceConfig, targetConfig, @@ -3459,8 +4351,9 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { instanceId: targetPaths.instanceId, seedMode, preserveLiveWork: opts.preserveLiveWork, + expectedCompanyId: nonEmpty(process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID) ?? undefined, + seedDatabase: seedWorktreeDatabase, }); - markWorktreeSeedComplete({ configPath: targetEndpoint.configPath, seedMode }); spinner.stop(`Reseeded ${targetEndpoint.label} (${seedMode}).`); p.log.message(pc.dim(`Source: ${source.configPath}`)); p.log.message(pc.dim(`Target: ${targetEndpoint.configPath}`)); @@ -3482,6 +4375,8 @@ async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise { } catch (error) { spinner.stop(pc.red("Failed to reseed worktree database.")); throw error; + } finally { + await releaseSeedLock(); } } @@ -3593,7 +4488,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") - .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) + .option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false) .option("--no-seed", "Skip database seeding from the source instance") .option("--force", "Replace existing repo-local config and isolated instance data", false) .action(worktreeMakeCommand); @@ -3610,7 +4505,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") - .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) + .option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false) .option("--no-seed", "Skip database seeding from the source instance") .option("--force", "Replace existing repo-local config and isolated instance data", false) .action(worktreeInitCommand); @@ -3629,7 +4524,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--from-config ", "Source config.json to seed from (defaults to the seed-pending marker)") .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config") - .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues", false) + .option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services", false) .action(worktreeEnsureSeededCommand); program @@ -3660,9 +4555,10 @@ export function registerWorktreeCommands(program: Command): void { .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config") .option("--seed-mode ", "Seed profile: minimal or full (default: full)", "full") - .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) + .option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false) .option("--yes", "Skip the destructive confirmation prompt", false) .option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false) + .option("--backup-target", "Retain a recoverable full backup of the isolated target DB before reseeding", false) .action(worktreeReseedCommand); worktree @@ -3674,7 +4570,7 @@ export function registerWorktreeCommands(program: Command): void { .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config (default: default)") .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") - .option("--preserve-live-work", "Do not quarantine copied agent timers or assigned open issues in the seeded worktree", false) + .option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false) .option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false) .option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false) .action(worktreeRepairCommand); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index a80589d8916..c64c9ce6c03 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -335,6 +335,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 @@ -477,6 +479,8 @@ Seed modes: Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments. +The same quarantine stops copied project/execution-workspace runtime desired states and clears copied runtime process claims. Without this reset, booting the cloned Paperclip database could restart a source workspace's dev service from the isolated instance, creating duplicate runners, port reassignment, and stale public URLs. + After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. `pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first. @@ -485,25 +489,29 @@ After `worktree init`, both the server and the CLI auto-load the repo-local `.pa Seeding a worktree database is the heaviest part of `worktree init`. That work can be deferred so a worktree is cheap to create and only pays the seed cost the first time it is actually used — the CLI/dev-time analog of the server's lazy runtime provisioning (see the board-operator guide's "Lazy runtime provisioning" section). -Seeding state is tracked with two marker files under the worktree's `.paperclip/` directory: +Seeding state is tracked in `.paperclip/seed-manifest.json`. The versioned manifest records only non-secret evidence: source instance id/config path, target instance id, seed mode, snapshot time, migration revision, attempt timestamps, current phase, terminal state, and a bounded phase diagnostic history. It never stores database credentials or auth credential material. The legacy `seed-pending` and `seed-complete` files remain read-only compatibility signals for worktrees created before the manifest shipped. -- `seed-pending` — the isolated database has not been seeded yet (a **lean** worktree). Written by `worktree init` before any seed runs. -- `seed-complete` — the database was seeded; the pending marker is removed. +The default `worktree init` still seeds eagerly. A lean worktree (created without an eager seed) has a `pending` manifest until something seeds it on demand: -The default `worktree init` still seeds eagerly and writes `seed-complete` immediately. A lean worktree (created without an eager seed) keeps its `seed-pending` marker until something seeds it on demand: +- `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 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 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. -- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: a present `seed-complete` marker or a missing `seed-pending` marker short-circuits it, so it is safe to call repeatedly and from concurrent processes. It reads the source instance from the `seed-pending` marker unless you pass `--from-config`. -- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot, so `run` transparently seeds a lean worktree on first launch. -- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the worktree is still `seed-pending`. An explicitly configured runtime provision command always takes precedence. -- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned). +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. -**Seed-pending guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose database is still `seed-pending` and points you at the fix: +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 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: ``` [paperclip] this worktree database is seed-pending. Run `pnpm paperclipai worktree ensure-seeded` before `pnpm dev`. ``` -This guard (`isWorktreeSeedPending` in `server/src/dev-runner-worktree.ts`) prevents `pnpm dev` from starting the app against an empty, unseeded database — run `worktree ensure-seeded` once and re-run `pnpm dev`. +This guard (`isWorktreeSeedPending` in `server/src/dev-runner-worktree.ts`) prevents `pnpm dev` from starting the app against an empty or partially restored database — run `worktree ensure-seeded` once and re-run `pnpm dev`. Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines. @@ -528,6 +536,27 @@ paperclipai worktree env eval "$(paperclipai worktree env)" ``` +### Workspace login handoff and readiness + +Opening a managed workspace board no longer depends on knowing which cloned password is current. `Open workspace` asks the main control plane for a short-lived, single-use ticket; the isolated workspace verifies it and creates its own instance-scoped Better Auth session. + +- **Issue** — `POST /api/execution-workspaces/{id}/login-handoff` (board actors only). The ticket is bound to the caller's user id and email, the execution workspace id, the workspace's company id, the isolated instance id, the live runtime origin, a nonce, and a ~90 s expiry. Nothing in the request body influences that binding; only the landing path is caller-supplied and it is reduced to a same-origin path before signing. +- **Exchange** — `GET /api/auth/{workspace-handoff}/exchange?ticket=…` on the workspace itself, registered as a Better Auth plugin so session creation and cookie signing use Better Auth's own path. It verifies the signature, expiry, origin, instance, workspace, company, the cloned user's email, and an active membership **in that company**, records the nonce so a replay loses, and answers with an HTTP redirect — which is what keeps the ticket out of browser history. Request logs redact the `ticket` parameter. +- **Fallback** — direct email/password sign-in still works and the UI labels it accurately as *snapshot-local credentials*. A rejected ticket redirects to `/auth?workspaceHandoffError=` rather than failing opaquely. + +Key material is derived, never shared. The control plane keeps a root secret (`PAPERCLIP_WORKSPACE_HANDOFF_SECRET`, or a domain-separated derivation from the instance's existing signing secret when that is unset) and injects only per-workspace values into the guest process: + +| Variable | Purpose | +| --- | --- | +| `PAPERCLIP_WORKSPACE_HANDOFF_KEY` | Per-workspace ticket verification key. A guest cannot mint a ticket for a sibling workspace. | +| `PAPERCLIP_WORKSPACE_READINESS_TOKEN` | Bearer token the control plane presents to read this workspace's protected readiness. | +| `PAPERCLIP_EXECUTION_WORKSPACE_ID` | Execution workspace the guest was provisioned for, used for identity checks. | +| `PAPERCLIP_EXECUTION_WORKSPACE_COMPANY_ID` | Company whose board the guest represents. Scopes both the membership check and the readiness probes, so "some company in the clone is fine" cannot pass for the one being opened. | + +Protected `/api/health` on a cloned workspace additionally carries a `workspace` block — `state`, `databaseReady`, `cloneDataReady`, `authHandoffReady`, `seedState`, `seedPhase`, `instanceId`, `executionWorkspaceId`, `failurePhase`. Public health stays redacted. Managed runtime start will not publish `running / healthy` unless that block agrees and names this exact instance and workspace, and runtime-service work products are refreshed from the live runtime row so a port change cannot leave a stale user-facing URL. + +The workspace UI surfaces `Provisioning database`, `Validating clone`, `Ready`, `Degraded`, `Repairing`, and `Repair failed`, each with one safe action (open, start, repair, or read the log). + ### Worktree CLI Reference **`npx paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree. @@ -609,6 +638,7 @@ For an already-created worktree where you want to keep the existing repo-local c | `--seed-mode ` | Seed profile: `minimal` or `full` (default: `full`) | | `--yes` | Skip the destructive confirmation prompt | | `--allow-live-target` | Override the guard that requires the target worktree DB to be stopped first | +| `--backup-target` | Retain a recoverable full target-DB backup before reseeding | Examples: @@ -628,6 +658,8 @@ npx paperclipai worktree reseed \ --seed-mode full ``` +Managed workspace repair uses this same verified full-reseed contract through `POST /api/execution-workspaces/:id/runtime-commands/repair`. The exclusive, audited operation stops managed services, writes a recoverable pre-repair database backup under the isolated instance's backup directory, performs the full seed/migration/quarantine/rebinding sequence, and restarts only after terminal manifest and service-health validation. It preserves the worktree filesystem. On failure, services remain stopped while the database backup, seed manifest, bounded phase diagnostics, and operation log are retained for inspection; repair never retries itself in a loop. + **`npx paperclipai worktree:make [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step. | Option | Description | @@ -673,6 +705,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. @@ -842,6 +879,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/package.json b/package.json index 61348b6879b..5ce2200ca02 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "connections:ingest-app-definitions": "node scripts/ingest-app-definitions.mjs" }, "devDependencies": { - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.62.1", "cross-env": "^10.1.0", "esbuild": "^0.28.1", "typescript": "^5.7.3", 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..22a458b1352 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -18,7 +18,45 @@ 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 + * 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 @@ -67,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 { @@ -446,6 +492,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/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 dcac81f41b2..8c1aba36343 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -115,6 +115,8 @@ export interface EffectiveSandboxCapabilities { readonly persistentProcessSessions: boolean; readonly independentControlCommands: boolean; readonly incrementalSessionOutput: boolean; + readonly concurrentSyncOperations: boolean; + readonly duplexCommandStream: boolean; } export interface AdapterSandboxExecutionTarget extends AdapterExecutionTargetWorkspaceMetadata { @@ -254,6 +256,8 @@ function parseEffectiveSandboxCapabilities(value: unknown): EffectiveSandboxCapa persistentProcessSessions: parsed.persistentProcessSessions === true, independentControlCommands: parsed.independentControlCommands === true, incrementalSessionOutput: parsed.incrementalSessionOutput === true, + concurrentSyncOperations: parsed.concurrentSyncOperations === true, + duplexCommandStream: parsed.duplexCommandStream === true, }; } diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 1a233e4c5ac..e7f1a6b9a50 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,117 @@ 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"); + }); + + 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 fd3525ecc05..6cae5e47247 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, `'\"'\"'`)}'`; } @@ -416,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; @@ -433,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) { @@ -456,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], { @@ -476,6 +568,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/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/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/ssh.ts b/packages/adapter-utils/src/ssh.ts index de86cdb678a..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 { 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], { @@ -974,6 +1008,7 @@ 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/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 1d612f13f01..a21b4e69af2 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -114,10 +114,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?: { @@ -503,6 +504,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/execute.acp-fallback.test.ts b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts index bf7baafcbe6..772fc70302d 100644 --- a/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts +++ b/packages/adapters/claude-local/src/server/execute.acp-fallback.test.ts @@ -113,7 +113,7 @@ describe("claude_local ACP startup fallback", () => { it("trusts the Paperclip API URL when network access is allowlisted", async () => { const paperclipApiUrl = "http://127.0.0.1:4310"; - vi.stubEnv("PAPERCLIP_RUNTIME_API_URL", paperclipApiUrl); + vi.stubEnv("PAPERCLIP_API_URL", paperclipApiUrl); const ctx = buildContext({ networkScope: "allowlist" }); await execute(ctx as never); 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 32e393eb772..5f51cdba2a2 100644 --- a/packages/adapters/codex-local/package.json +++ b/packages/adapters/codex-local/package.json @@ -52,8 +52,9 @@ "probe:quota": "pnpm exec tsx src/cli/quota-probe.ts --json" }, "dependencies": { - "@agentclientprotocol/codex-acp": "^1.1.4", + "@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/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/packages/adapters/hermes/gateway-ui-parser.cjs b/packages/adapters/hermes/gateway-ui-parser.cjs index 4654dbdef87..5d67ff4ce07 100644 --- a/packages/adapters/hermes/gateway-ui-parser.cjs +++ b/packages/adapters/hermes/gateway-ui-parser.cjs @@ -23,6 +23,31 @@ function asString(value) { return typeof value === "string" ? value : ""; } +// Pull the reasoning text out of a `reasoning.available` event payload. +// The exact field name is defined by the external Hermes gateway, so this +// checks the plausible field names (mirroring the fallback chain used by +// extractOutput() in gateway/server/execute.ts) and recurses one level into +// nested `data` / `payload` records. +function extractDirectReasoningText(data) { + return ( + asString(data.reasoning).trim() || + asString(data.reasoning_text).trim() || + asString(data.thinking).trim() || + asString(data.text).trim() || + asString(data.summary).trim() || + asString(data.content).trim() + ); +} + +function extractReasoningText(data) { + if (!data) return ""; + const direct = extractDirectReasoningText(data); + if (direct) return stripAnsi(direct); + const nested = asRecord(data.data) || asRecord(data.payload); + const nestedDirect = nested ? extractDirectReasoningText(nested) : ""; + return nestedDirect ? stripAnsi(nestedDirect) : ""; +} + function parseStdoutLine(line, ts) { const cleaned = stripAnsi(line); const trimmed = cleaned.trim(); @@ -41,7 +66,8 @@ function parseStdoutLine(line, ts) { return [{ kind: "stderr", ts, text: message }]; } if (eventName === "reasoning.available") { - return [{ kind: "thinking", ts, text: "Hermes reasoning available" }]; + const reasoning = extractReasoningText(data); + return [{ kind: "thinking", ts, text: reasoning || "Hermes reasoning available" }]; } return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }]; } diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.test.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.test.ts new file mode 100644 index 00000000000..aa0b354166b --- /dev/null +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { parseHermesGatewayStdoutLine } from "./parse-stdout.js"; + +const TS = "2026-07-08T12:00:00.000Z"; + +function eventLine(eventName: string, data: unknown): string { + return `[hermes-gateway:event] run=run-123 event=${eventName} data=${JSON.stringify(data)}`; +} + +describe("parseHermesGatewayStdoutLine — reasoning.available payload extraction", () => { + // This is the assertion that FAILS on the old hardcoded-placeholder code + // and PASSES once the real reasoning text is extracted from `data`. + it("uses the real reasoning text from data.text instead of the hardcoded placeholder", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { text: "Considering three approaches to the cache invalidation bug." }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Considering three approaches to the cache invalidation bug.", + }); + expect(result[0]).not.toHaveProperty("text", "Hermes reasoning available"); + }); + + it("uses the real reasoning text from data.summary", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { summary: "Weighing tradeoffs between two refactor strategies." }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Weighing tradeoffs between two refactor strategies.", + }); + }); + + it("recurses one level into a nested data.data record to find the reasoning text", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { data: { text: "Nested reasoning payload from gateway wrapper." } }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Nested reasoning payload from gateway wrapper.", + }); + }); + + it("recurses one level into a nested data.payload record to find the reasoning text", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { payload: { reasoning: "Nested via payload wrapper instead of data." } }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Nested via payload wrapper instead of data.", + }); + }); + + it("falls back to the placeholder when data has no recognizable text field (preserves bare-signal behavior)", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { unrelatedField: 42 }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "thinking", ts: TS, text: "Hermes reasoning available" }); + }); + + it("falls back to the placeholder when data is entirely absent/unparseable", () => { + const result = parseHermesGatewayStdoutLine( + "[hermes-gateway:event] run=run-123 event=reasoning.available data=not-json", + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "thinking", ts: TS, text: "Hermes reasoning available" }); + }); +}); + +describe("parseHermesGatewayStdoutLine — regression guards for unrelated handlers", () => { + it("still yields an assistant delta part for message.delta", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("message.delta", { delta: "Hello there" }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "assistant", ts: TS, text: "Hello there", delta: true }); + }); + + it("still yields a stdout part for a plain non-event line", () => { + const result = parseHermesGatewayStdoutLine("just a plain line of output", TS); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "stdout", ts: TS, text: "just a plain line of output" }); + }); +}); diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts index 2837bea15ef..894f9235154 100644 --- a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts @@ -27,6 +27,33 @@ function asString(value: unknown): string { return typeof value === "string" ? value : ""; } +/** + * Pull the reasoning text out of a `reasoning.available` event payload. + * The exact field name is defined by the external Hermes gateway, so this + * checks the plausible field names (mirroring the fallback chain used by + * extractOutput() in gateway/server/execute.ts) and recurses one level into + * nested `data` / `payload` records. + */ +function extractDirectReasoningText(data: Record): string { + return ( + asString(data.reasoning).trim() || + asString(data.reasoning_text).trim() || + asString(data.thinking).trim() || + asString(data.text).trim() || + asString(data.summary).trim() || + asString(data.content).trim() + ); +} + +function extractReasoningText(data: Record | null): string { + if (!data) return ""; + const direct = extractDirectReasoningText(data); + if (direct) return stripAnsi(direct); + const nested = asRecord(data.data) ?? asRecord(data.payload); + const nestedDirect = nested ? extractDirectReasoningText(nested) : ""; + return nestedDirect ? stripAnsi(nestedDirect) : ""; +} + export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { const cleaned = stripAnsi(line); const trimmed = cleaned.trim(); @@ -45,7 +72,8 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri return [{ kind: "stderr", ts, text: message }]; } if (eventName === "reasoning.available") { - return [{ kind: "thinking", ts, text: "Hermes reasoning available" }]; + const reasoning = extractReasoningText(data); + return [{ kind: "thinking", ts, text: reasoning || "Hermes reasoning available" }]; } return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }]; } 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/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/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/client.ts b/packages/db/src/client.ts index 99b8f8a17dd..c0337620491 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -35,13 +35,20 @@ function splitMigrationStatements(content: string): string[] { } export type MigrationState = - | { status: "upToDate"; tableCount: number; availableMigrations: string[]; appliedMigrations: string[] } + | { + status: "upToDate"; + tableCount: number; + availableMigrations: string[]; + appliedMigrations: string[]; + journalEntryCount: number; + } | { status: "needsMigrations"; tableCount: number; availableMigrations: string[]; appliedMigrations: string[]; pendingMigrations: string[]; + journalEntryCount: number; reason: "no-migration-journal-empty-db" | "no-migration-journal-non-empty-db" | "pending-migrations"; }; @@ -683,6 +690,7 @@ export async function inspectMigrations(url: string): Promise { availableMigrations, appliedMigrations: [], pendingMigrations: availableMigrations, + journalEntryCount: 0, reason: "no-migration-journal-non-empty-db", }; } @@ -693,10 +701,16 @@ export async function inspectMigrations(url: string): Promise { availableMigrations, appliedMigrations: [], pendingMigrations: availableMigrations, + journalEntryCount: 0, reason: "no-migration-journal-empty-db", }; } + const qualifiedMigrationTable = `${quoteIdentifier(migrationTableSchema)}.${quoteIdentifier(DRIZZLE_MIGRATIONS_TABLE)}`; + const journalCountRows = await sql.unsafe<{ count: number }[]>( + `SELECT count(*)::int AS count FROM ${qualifiedMigrationTable}`, + ); + const journalEntryCount = journalCountRows[0]?.count ?? 0; const appliedMigrations = await loadAppliedMigrations(sql, migrationTableSchema, availableMigrations); const pendingMigrations = availableMigrations.filter((name) => !appliedMigrations.includes(name)); if (pendingMigrations.length === 0) { @@ -705,6 +719,7 @@ export async function inspectMigrations(url: string): Promise { tableCount, availableMigrations, appliedMigrations, + journalEntryCount, }; } @@ -714,6 +729,7 @@ export async function inspectMigrations(url: string): Promise { availableMigrations, appliedMigrations, pendingMigrations, + journalEntryCount, reason: "pending-migrations", }; } finally { 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/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/0224_flippant_goliath.sql b/packages/db/src/migrations/0226_sharp_lilandra.sql similarity index 100% rename from packages/db/src/migrations/0224_flippant_goliath.sql rename to packages/db/src/migrations/0226_sharp_lilandra.sql 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/0224_snapshot.json b/packages/db/src/migrations/meta/0226_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0224_snapshot.json rename to packages/db/src/migrations/meta/0226_snapshot.json index 019f63475ee..85bd1a04b0b 100644 --- a/packages/db/src/migrations/meta/0224_snapshot.json +++ b/packages/db/src/migrations/meta/0226_snapshot.json @@ -1,6 +1,6 @@ { - "id": "8553adfc-b438-4f01-b88f-b1100a9077d8", - "prevId": "d925c84b-41b6-42d1-a0f9-4ee090ef3cd1", + "id": "f5748a8a-c6ea-4d9c-b023-1cd12d812bc5", + "prevId": "5f26d1e4-4e78-4976-8a1f-1bd860931b8d", "version": "7", "dialect": "postgresql", "tables": { @@ -279,6 +279,12 @@ "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", @@ -304,6 +310,12 @@ "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", @@ -353,8 +365,8 @@ "method": "btree", "with": {} }, - "adapter_auth_sessions_company_adapter_active_uq": { - "name": "adapter_auth_sessions_company_adapter_active_uq", + "adapter_auth_sessions_company_owner_adapter_active_uq": { + "name": "adapter_auth_sessions_company_owner_adapter_active_uq", "columns": [ { "expression": "company_id", @@ -362,6 +374,12 @@ "asc": true, "nulls": "last" }, + { + "expression": "started_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, { "expression": "adapter_type", "isExpression": false, @@ -370,7 +388,22 @@ } ], "isUnique": true, - "where": "\"adapter_auth_sessions\".\"status\" IN ('starting', 'waiting_for_user', 'promoting')", + "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": {} @@ -4536,187 +4569,6 @@ }, "isRLSEnabled": false }, - "public.claude_setup_token_sessions": { - "name": "claude_setup_token_sessions", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "company_id": { - "name": "company_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "owner_user_id": { - "name": "owner_user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "adapter_type": { - "name": "adapter_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "environment_id": { - "name": "environment_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "lease_id": { - "name": "lease_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'starting'" - }, - "deadline_at": { - "name": "deadline_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "bound_at": { - "name": "bound_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": { - "claude_setup_token_sessions_active_uq": { - "name": "claude_setup_token_sessions_active_uq", - "columns": [ - { - "expression": "company_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "owner_user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "adapter_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "environment_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"claude_setup_token_sessions\".\"state\" IN ('starting', 'awaiting_code', 'submitting', 'persisting')", - "concurrently": false, - "method": "btree", - "with": {} - }, - "claude_setup_token_sessions_session_id_uq": { - "name": "claude_setup_token_sessions_session_id_uq", - "columns": [ - { - "expression": "session_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "claude_setup_token_sessions_deadline_idx": { - "name": "claude_setup_token_sessions_deadline_idx", - "columns": [ - { - "expression": "deadline_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "claude_setup_token_sessions_company_id_companies_id_fk": { - "name": "claude_setup_token_sessions_company_id_companies_id_fk", - "tableFrom": "claude_setup_token_sessions", - "tableTo": "companies", - "columnsFrom": [ - "company_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "claude_setup_token_sessions_environment_id_environments_id_fk": { - "name": "claude_setup_token_sessions_environment_id_environments_id_fk", - "tableFrom": "claude_setup_token_sessions", - "tableTo": "environments", - "columnsFrom": [ - "environment_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, "public.cli_auth_challenges": { "name": "cli_auth_challenges", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 5629ace9854..77ee578e438 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1559,8 +1559,22 @@ { "idx": 224, "version": "7", - "when": 1787070369923, - "tag": "0224_flippant_goliath", + "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 + }, + { + "idx": 226, + "version": "7", + "when": 1787173793193, + "tag": "0226_sharp_lilandra", "breakpoints": true } ] 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 ac1671c1fe8..62af02ac829 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/db/vitest.config.ts b/packages/db/vitest.config.ts index f624398e8de..c1433e6ef38 100644 --- a/packages/db/vitest.config.ts +++ b/packages/db/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + include: ["src/**/*.test.ts"], }, }); 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 a7e19fdc900..149d284a305 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -1,10 +1,16 @@ 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"; +// 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, @@ -31,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"], @@ -44,10 +58,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/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/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/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 2820add850b..b3c311ab33a 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 { nativeFinalizationResultSchema, nativeFinalizationResultV1Schema, @@ -890,6 +891,11 @@ export type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus, + WorkspaceLoginHandoffTicketResponse, + WorkspaceReadiness, + WorkspaceReadinessProbeResult, + WorkspaceReadinessState, + WorkspaceSeedReadinessState, NormalizedWorkspaceFileAvailabilityQuery, WorkspaceFileAvailabilityQuery, WorkspaceFileAvailabilityRequest, @@ -1450,6 +1456,7 @@ export type { QuotaWindow, ProviderQuotaResult, } from "./types/index.js"; +export { WORKSPACE_READINESS_STATES } from "./types/index.js"; export { COMPANY_SEARCH_EXTRACT_KINDS, COMPANY_SEARCH_EXTRACT_SCOPES, @@ -2451,6 +2458,7 @@ export { startEnvironmentCustomImageSetupSessionSchema, finishEnvironmentCustomImageSetupSessionSchema, cancelEnvironmentCustomImageSetupSessionSchema, + relinkEnvironmentCustomImageTemplateSchema, createEnvironmentCustomImageTerminalSessionTokenSchema, environmentCustomImageTerminalSessionTokenSchema, type EnvironmentCustomImageSetupConnectionSummary, @@ -2459,6 +2467,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/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/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/index.ts b/packages/shared/src/types/index.ts index 77999a35b6f..8f7a85e5787 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -413,6 +413,14 @@ export type { WorkspaceOperationPhase, WorkspaceOperationStatus, } from "./workspace-operation.js"; +export { WORKSPACE_READINESS_STATES } from "./workspace-readiness.js"; +export type { + WorkspaceLoginHandoffTicketResponse, + WorkspaceReadiness, + WorkspaceReadinessProbeResult, + WorkspaceReadinessState, + WorkspaceSeedReadinessState, +} from "./workspace-readiness.js"; export type { NormalizedWorkspaceFileAvailabilityQuery, WorkspaceFileAvailabilityQuery, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 3268a3a847e..30ff21d289f 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -163,6 +163,27 @@ 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; + /** + * 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 { @@ -216,10 +237,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/types/workspace-operation.ts b/packages/shared/src/types/workspace-operation.ts index d1ccf6622d5..8fcb52c7088 100644 --- a/packages/shared/src/types/workspace-operation.ts +++ b/packages/shared/src/types/workspace-operation.ts @@ -2,7 +2,9 @@ export type WorkspaceOperationPhase = | "worktree_prepare" | "workspace_config_freshness" | "workspace_provision" + | "workspace_seed" | "workspace_runtime_provision" + | "workspace_repair" | "workspace_teardown" | "worktree_cleanup" | "workspace_finalize"; diff --git a/packages/shared/src/types/workspace-readiness.ts b/packages/shared/src/types/workspace-readiness.ts new file mode 100644 index 00000000000..c979f99c3dc --- /dev/null +++ b/packages/shared/src/types/workspace-readiness.ts @@ -0,0 +1,91 @@ +/** + * Workspace readiness contract (PAP-17572). + * + * Transport health ("the port answered") is not user readiness. A cloned + * workspace is only usable when its own database is reachable, the seed + * manifest reached a verified terminal state, representative cloned rows are + * readable, the password-independent login handoff is configured, and the + * instance/workspace/company identity matches the one the control plane expects. + * + * These fields ride on the *protected* health response only. Public health + * stays redacted: an anonymous caller learns liveness, never which instance or + * execution workspace answered. + */ + +export const WORKSPACE_READINESS_STATES = [ + /** The isolated database is still being restored. */ + "provisioning", + /** Restore finished; the clone is being validated. */ + "validating", + /** Every readiness signal agrees; the workspace can be opened. */ + "ready", + /** Serving, but at least one readiness signal regressed after being ready. */ + "degraded", + /** A managed repair is running right now. */ + "repairing", + /** A terminal provisioning or repair failure needs an operator action. */ + "failed", +] as const; + +export type WorkspaceReadinessState = (typeof WORKSPACE_READINESS_STATES)[number]; + +/** Terminal state of the versioned seed manifest, or `unknown` for a legacy marker. */ +export type WorkspaceSeedReadinessState = + | "pending" + | "running" + | "verified" + | "failed" + | "unknown" + | "absent"; + +export interface WorkspaceReadiness { + state: WorkspaceReadinessState; + /** `SELECT 1` plus a readable migration journal on this instance's own database. */ + databaseReady: boolean; + /** A representative cloned company/issue pair is readable. */ + cloneDataReady: boolean; + /** A signing key and a resolvable cloned admin identity are both present. */ + authHandoffReady: boolean; + /** Exact cloned user checked for a caller-scoped handoff probe, or null. */ + authHandoffUserId: string | null; + seedState: WorkspaceSeedReadinessState; + /** Last recorded seed phase, e.g. `restore` or `post_restore_validation`. */ + seedPhase: string | null; + seedMode: "minimal" | "full" | null; + /** Resolved instance identity of the process that answered, never a branch heuristic. */ + instanceId: string | null; + /** Execution workspace this instance was provisioned for, when known. */ + executionWorkspaceId: string | null; + /** Company whose cloned board this workspace serves, when known. */ + companyId: string | null; + /** Phase that failed, so operators do not need host-shell archaeology. */ + failurePhase: string | null; +} + +/** + * Result of the control plane's protected readiness probe against a managed + * workspace runtime. `reason` is a stable machine code so the runtime gate and + * the UI can branch without parsing prose. + */ +export type WorkspaceReadinessProbeResult = + | { ok: true; readiness: WorkspaceReadiness } + | { + ok: false; + reason: + | "unreachable" + | "http_error" + | "unhealthy_payload" + | "readiness_missing" + | "not_ready" + | "identity_mismatch"; + readiness: WorkspaceReadiness | null; + detail: string | null; + }; + +export interface WorkspaceLoginHandoffTicketResponse { + /** Absolute URL that exchanges the ticket and redirects to the cloned board. */ + url: string; + expiresAt: string; + /** `handoff` for the signed path, `credentials` when only the fallback is available. */ + mode: "handoff" | "credentials"; +} 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 27e5645cc96..a7a110ad762 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -95,6 +95,7 @@ export { startEnvironmentCustomImageSetupSessionSchema, finishEnvironmentCustomImageSetupSessionSchema, cancelEnvironmentCustomImageSetupSessionSchema, + relinkEnvironmentCustomImageTemplateSchema, createEnvironmentCustomImageTerminalSessionTokenSchema, environmentCustomImageTerminalSessionTokenSchema, type EnvironmentCustomImageSetupConnectionSummary, @@ -103,6 +104,7 @@ export { type StartEnvironmentCustomImageSetupSession, type FinishEnvironmentCustomImageSetupSession, type CancelEnvironmentCustomImageSetupSession, + type RelinkEnvironmentCustomImageTemplate, type CreateEnvironmentCustomImageTerminalSessionToken, type EnvironmentCustomImageTerminalSessionToken, } from "./environment-custom-images.js"; diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index 4cd52fe0590..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", () => { @@ -350,3 +361,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..37b959efefa 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(), @@ -166,10 +166,15 @@ export const sandboxProviderCapabilitiesSchema = z.object({ persistentProcessSessions: z.boolean().optional(), independentControlCommands: z.boolean().optional(), incrementalSessionOutput: z.boolean().optional(), + concurrentSyncOperations: z.boolean().optional(), + duplexCommandStream: z.boolean().optional(), }).strict(); 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 +192,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/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.test.ts b/packages/shared/src/worktree-seed-source.test.ts new file mode 100644 index 00000000000..7f54e80cc19 --- /dev/null +++ b/packages/shared/src/worktree-seed-source.test.ts @@ -0,0 +1,61 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveCanonicalWorktreeSeedSource } from "./worktree-seed-source.js"; + +const cleanup: string[] = []; + +function makeInstance(prefix: string, instanceId: string) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + cleanup.push(cwd); + const configDir = path.join(cwd, ".paperclip"); + const configPath = path.join(configDir, "config.json"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(configPath, "{}\n"); + fs.writeFileSync(path.join(configDir, ".env"), `PAPERCLIP_INSTANCE_ID=${instanceId}\n`); + return { cwd, configPath, instanceId }; +} + +afterEach(() => { + for (const dir of cleanup.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("resolveCanonicalWorktreeSeedSource", () => { + it("returns only the registered base workspace config", () => { + const source = makeInstance("paperclip-seed-source-", "source-instance"); + const target = makeInstance("paperclip-seed-target-", "target-instance"); + + expect(resolveCanonicalWorktreeSeedSource({ + registeredBaseWorkspaceCwd: source.cwd, + targetConfigPath: target.configPath, + expectedTargetInstanceId: target.instanceId, + manifestSource: { configPath: source.configPath, instanceId: source.instanceId }, + manifestTargetInstanceId: target.instanceId, + })).toMatchObject({ + baseWorkspaceCwd: source.cwd, + configPath: source.configPath, + targetConfigPath: target.configPath, + }); + }); + + it("fails closed without registration and when source equals target", () => { + const target = makeInstance("paperclip-seed-same-target-", "target-instance"); + const diagnostic = { configPath: target.configPath, instanceId: target.instanceId }; + + expect(() => resolveCanonicalWorktreeSeedSource({ + targetConfigPath: target.configPath, + expectedTargetInstanceId: target.instanceId, + manifestSource: diagnostic, + manifestTargetInstanceId: target.instanceId, + })).toThrow(/not registered/); + + expect(() => resolveCanonicalWorktreeSeedSource({ + registeredBaseWorkspaceCwd: target.cwd, + targetConfigPath: target.configPath, + expectedTargetInstanceId: target.instanceId, + manifestSource: diagnostic, + manifestTargetInstanceId: target.instanceId, + })).toThrow(/same canonical file/); + }); +}); diff --git a/packages/shared/src/worktree-seed-source.ts b/packages/shared/src/worktree-seed-source.ts new file mode 100644 index 00000000000..fca8556befa --- /dev/null +++ b/packages/shared/src/worktree-seed-source.ts @@ -0,0 +1,160 @@ +import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; + +export type WorktreeSeedSourceDiagnostic = { + configPath?: unknown; + instanceId?: unknown; +}; + +export type CanonicalWorktreeSeedSource = { + baseWorkspaceCwd: string | null; + configPath: string; + instanceId: string; + targetConfigPath: string; + 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)) { + throw new Error(`Registered ${label} Paperclip config is missing its adjacent .env instance pointer.`); + } + const contents = readFileSync(envPath, "utf8"); + for (const rawLine of contents.split(/\r?\n/)) { + const match = rawLine.match( + /^\s*(?:export\s+)?PAPERCLIP_INSTANCE_ID\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))/, + ); + const value = (match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim(); + if (value) return value; + } + throw new Error(`Registered ${label} Paperclip config has no PAPERCLIP_INSTANCE_ID binding.`); +} + +function canonicalRegularFile(filePath: string, label: string): string { + const resolved = path.resolve(filePath); + let canonical: string; + try { + canonical = realpathSync(resolved); + } catch { + throw new Error(`${label} does not exist at ${resolved}.`); + } + if (canonical !== resolved || lstatSync(resolved).isSymbolicLink()) { + throw new Error(`${label} must be a canonical path and cannot use a symlink alias.`); + } + if (!lstatSync(canonical).isFile()) { + throw new Error(`${label} is not a regular file at ${canonical}.`); + } + return canonical; +} + +/** 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) { + throw new Error( + "Worktree seed source is not registered. Managed boot requires a project workspace; manual boot requires --from-config.", + ); + } + + let canonicalBaseCwd: string | null = null; + let registeredConfigPath: string | null = null; + if (registeredCwd) { + const resolvedRegisteredCwd = path.resolve(registeredCwd); + try { + canonicalBaseCwd = realpathSync(resolvedRegisteredCwd); + } catch { + throw new Error(`Registered base project workspace does not exist at ${resolvedRegisteredCwd}.`); + } + if (canonicalBaseCwd !== resolvedRegisteredCwd) { + throw new Error("Registered base project workspace must be canonical and cannot use a symlink alias."); + } + if (!lstatSync(canonicalBaseCwd).isDirectory()) { + throw new Error(`Registered base project workspace is not a directory at ${canonicalBaseCwd}.`); + } + registeredConfigPath = path.join(canonicalBaseCwd, ".paperclip", "config.json"); + } + + const selectedPath = registeredConfigPath ?? explicitSource!; + const canonicalSourceConfigPath = canonicalRegularFile(selectedPath, "Registered source Paperclip config"); + if (registeredConfigPath && canonicalSourceConfigPath !== registeredConfigPath) { + throw new Error("Registered source Paperclip config escapes the base project workspace or uses a symlink alias."); + } + + if (explicitSource) { + const canonicalExplicitSource = canonicalRegularFile(explicitSource, "Explicit source Paperclip config"); + if (canonicalExplicitSource !== canonicalSourceConfigPath) { + throw new Error("Explicit source Paperclip config does not match the registered base project workspace."); + } + } + + const canonicalTargetConfigPath = canonicalRegularFile( + input.targetConfigPath, + "Target worktree Paperclip config", + ); + if (canonicalSourceConfigPath === canonicalTargetConfigPath) { + throw new Error("Source and target Paperclip configs are the same canonical file."); + } + + const sourceInstanceId = readInstanceId(canonicalSourceConfigPath, "source"); + const targetInstanceId = readInstanceId(canonicalTargetConfigPath, "target"); + if (targetInstanceId !== input.expectedTargetInstanceId) { + throw new Error("Target Paperclip instance does not match the registered worktree instance."); + } + if (sourceInstanceId === targetInstanceId) { + 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() + : ""; + if (!diagnosticPath) { + throw new Error("Worktree seed manifest is missing source path diagnostics."); + } + const canonicalDiagnosticPath = canonicalRegularFile( + diagnosticPath, + "Worktree seed manifest source diagnostic", + ); + 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 !== registered.instanceId) { + throw new Error("Worktree seed manifest source instance does not match the registered source instance."); + } + if (input.manifestTargetInstanceId !== registered.targetInstanceId) { + throw new Error("Worktree seed manifest target instance does not match the registered target instance."); + } + return registered; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0313d7eec7..b24014d3009 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,8 +32,8 @@ importers: .: devDependencies: '@playwright/test': - specifier: ^1.61.1 - version: 1.61.1 + specifier: ^1.62.1 + version: 1.62.1 cross-env: specifier: ^10.1.0 version: 10.1.0 @@ -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.2.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: @@ -102,7 +102,7 @@ importers: version: 17.4.2 drizzle-orm: specifier: 0.45.2 - version: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7) + version: 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) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -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 @@ -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 @@ -158,11 +161,14 @@ importers: packages/adapters/codex-local: dependencies: '@agentclientprotocol/codex-acp': - specifier: ^1.1.4 - version: 1.1.7 + specifier: ^1.2.0 + version: 1.2.0 '@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 @@ -177,8 +183,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 @@ -258,7 +264,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.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 +280,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.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: @@ -285,8 +291,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 @@ -337,7 +343,7 @@ importers: version: link:../shared drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7) + version: 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) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -352,14 +358,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.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.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 +387,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.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 +406,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.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 +428,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.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 +478,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.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 +596,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.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 +639,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.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 +673,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.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 +730,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.12)) packages/teams-catalog: devDependencies: @@ -735,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 @@ -792,8 +798,8 @@ importers: specifier: ^3.0.1 version: 3.0.1(ajv@8.20.0) better-auth: - specifier: 1.6.25 - version: 1.6.25(@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.4)(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.2.0))(vite@6.4.3(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.1))) + 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.12))) chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -808,7 +814,7 @@ importers: version: 17.4.2 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7) + version: 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) embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) @@ -817,13 +823,13 @@ importers: version: 5.2.1 jsdom: specifier: ^28.1.0 - version: 28.1.0(@noble/hashes@2.2.0) + version: 28.1.0(@noble/hashes@2.3.0) multer: 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 @@ -840,8 +846,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 @@ -877,17 +883,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.2.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: @@ -895,8 +901,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) @@ -953,7 +959,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) @@ -994,8 +1000,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) @@ -1003,8 +1009,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 @@ -1013,17 +1019,17 @@ 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.0 - version: 10.5.0(@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)) + 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)) '@storybook/react-vite': - specifier: 10.5.0 - version: 10.5.0(@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)) + 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)(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,22 +1041,22 @@ 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) 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 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.2.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: @@ -1065,8 +1071,8 @@ packages: engines: {node: '>=22'} hasBin: true - '@agentclientprotocol/codex-acp@1.1.7': - resolution: {integrity: sha512-bhFLbGtOMEw6+PAp33vNERb6dXlULOfV3mWbRdps4v7sY7PHha/C2T1dnlG0yVcvBu9W+NYPzL0CAupnVoFTiQ==} + '@agentclientprotocol/codex-acp@1.2.0': + resolution: {integrity: sha512-nj23pM4OfaCOB5Du5rsSq0kcyki5H8D5ql96+AwIv7lnu0YEKj9R2YDsxbOKzwLlt+5QD6s0mn4Grkm7SSAUUA==} hasBin: true '@agentclientprotocol/sdk@1.2.1': @@ -1202,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': @@ -1302,8 +1308,8 @@ packages: resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.28.6': @@ -1383,8 +1389,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1416,20 +1422,20 @@ packages: resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + 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 +1451,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 @@ -1455,14 +1461,14 @@ packages: '@types/react': optional: true - '@better-auth/core@1.6.25': - resolution: {integrity: sha512-lMTlhtwyK4NpY9kPF+2rQCRKYpg136d3gM2xl8esxT1PjJx5Nh5YwZvxcYCIjDuO759sx6TCloJTuwcZGG6ZBw==} + '@better-auth/core@1.6.28': + resolution: {integrity: sha512-UzRh0Tx8OFmjl2iN+e+wNb3oZaBIXLw/DNgTotaEbatZdQePpekjq/0WBOHkLh6XEuOWQpF1VSJD8stgrQvP2Q==} peerDependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@cloudflare/workers-types': '>=4' '@opentelemetry/api': ^1.9.0 - better-call: 1.3.7 + better-call: 1.4.0 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 @@ -1472,46 +1478,46 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.25': - resolution: {integrity: sha512-ru/DeKjFPQUVeKkxF/ScazmPqIY7lwfkAV5Yt4j24wmn1Y8vFwoiPRnHgXUeZqBs10+nubaRwEqLF39CP6EhRw==} + '@better-auth/drizzle-adapter@1.6.28': + resolution: {integrity: sha512-TXuiTUuAqwQ6vlLS4X2yPl+cqhqPBtbxsC8mqyVVpg5UlUmxPBv89iGDue/fROIqjHFX8rjkD5f8IiDTyOSlnw==} peerDependencies: - '@better-auth/core': ^1.6.25 + '@better-auth/core': ^1.6.28 '@better-auth/utils': 0.4.2 drizzle-orm: ^0.45.2 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.6.25': - resolution: {integrity: sha512-zxiePhtN1YClS1irKYPVwWfN6kYp+QoYlz1hdQUOj8hXyo2aE/ny4RNAb6v332b0+U6Vu88EhYITRPdmvCo6uA==} + '@better-auth/kysely-adapter@1.6.28': + resolution: {integrity: sha512-EJ8qhdIvOAb+lisGIaWT4T1a8S2Fs2O22U+1GvTYhyra4+MVRakNLkn9og6rH2ILRibTpJ4itzanhpuWrkah3A==} peerDependencies: - '@better-auth/core': ^1.6.25 + '@better-auth/core': ^1.6.28 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.25': - resolution: {integrity: sha512-GhEzTumc8yfTz+OZ6pMg06BA49xob49x1bX+1mEl/FStDJoSF+6mTfI5M2ytFxaiN89336/aUjkW8u+qRyLexw==} + '@better-auth/memory-adapter@1.6.28': + resolution: {integrity: sha512-fl000Re8x8RsXNooZ7BJEq19f5UMOnbdkQKkY5qQJmLwWo/Sa1ODkfj6Mq1knFkOdJ5e6ABU2lh3a8WyqSH2aA==} peerDependencies: - '@better-auth/core': ^1.6.25 + '@better-auth/core': ^1.6.28 '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.25': - resolution: {integrity: sha512-ZtMmjcOdXR2Ziqx5y8ptTOaNpe0snNfALbBUPXJsgeyeRkDJDYzyLZ8MpuvNBTNllNeIFDbiXWAK5k+pEBZrUQ==} + '@better-auth/mongo-adapter@1.6.28': + resolution: {integrity: sha512-tJUmg7Z/TeOzpcdGceTkHVuyViVuHcradK1CFonLBuW4Vv+R5w8e2g9xuXOfjH4Wt9ZKd0LxyXWhpVVWWtyIQg==} peerDependencies: - '@better-auth/core': ^1.6.25 + '@better-auth/core': ^1.6.28 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.6.25': - resolution: {integrity: sha512-ym7B6Iqcry+/4aQnYpFwqP/GBIiXvjrm/5B6+0qmx8mkTY/apHFTpHuGzUYYNf4vPTtzF3eYY2+s2GOsomKaRg==} + '@better-auth/prisma-adapter@1.6.28': + resolution: {integrity: sha512-Fr26MGainOZN6Oidz/AxiVDrHE5PN2v2z7hTlQGbHC4ZaVV8wrOt+QoQcJhQHZXJSzmOnVAQwW7dj3TWDbZhHA==} peerDependencies: - '@better-auth/core': ^1.6.25 + '@better-auth/core': ^1.6.28 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -1521,16 +1527,19 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.6.25': - resolution: {integrity: sha512-2ZfC9lp7tU6Jw/q2Lz/bKfQqGMdMwc/IQDTYdBhvtGi24qInYVnhp2ZCW57hHM9j+fq1ULOtxgg6M3T1LEaihw==} + '@better-auth/telemetry@1.6.28': + resolution: {integrity: sha512-FTa0TTcW1/FsodjnmE1oisAXg6lMjKEhgQExNafF05oHW624Qrz7GU7eFuBV/0qj5Jn3yIYyOsbqkVTRvSwKnw==} peerDependencies: - '@better-auth/core': ^1.6.25 + '@better-auth/core': ^1.6.28 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@better-auth/utils@0.4.2': resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} @@ -1729,38 +1738,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': @@ -1886,6 +1895,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'} @@ -1910,6 +1925,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'} @@ -1934,6 +1955,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'} @@ -1958,6 +1985,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'} @@ -1982,6 +2015,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'} @@ -2006,6 +2045,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'} @@ -2030,6 +2075,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'} @@ -2054,6 +2105,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'} @@ -2078,6 +2135,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'} @@ -2102,6 +2165,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'} @@ -2126,6 +2195,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'} @@ -2150,6 +2225,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'} @@ -2174,6 +2255,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'} @@ -2198,6 +2285,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'} @@ -2222,6 +2315,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'} @@ -2246,6 +2345,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'} @@ -2270,6 +2375,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'} @@ -2288,6 +2399,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'} @@ -2312,6 +2429,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'} @@ -2330,6 +2453,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'} @@ -2354,6 +2483,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'} @@ -2372,6 +2507,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'} @@ -2396,6 +2537,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'} @@ -2420,6 +2567,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'} @@ -2444,6 +2597,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'} @@ -2468,6 +2627,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} @@ -2987,16 +3152,16 @@ packages: '@emnapi/core': ^2.0.0-alpha.3 '@emnapi/runtime': ^2.0.0-alpha.3 - '@noble/ciphers@2.2.0': - resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} + '@noble/ciphers@2.3.0': + resolution: {integrity: sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw==} engines: {node: '>= 20.19.0'} '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@noble/hashes@2.2.0': - resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} '@npmcli/fs@1.1.1': @@ -3010,43 +3175,43 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - '@openai/codex@0.145.0': - resolution: {integrity: sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ==} + '@openai/codex@0.147.0': + resolution: {integrity: sha512-EQLEXecAG2ptxI7UpBMo2TR/ga5596/c/OsYF/0LoUDh5JANZ7IoGqlzBEWbuEVQ76JePIbtTW/ihCkp1a7Z3w==} engines: {node: '>=16'} hasBin: true - '@openai/codex@0.145.0-darwin-arm64': - resolution: {integrity: sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA==} + '@openai/codex@0.147.0-darwin-arm64': + resolution: {integrity: sha512-BEUVkiOW7kLcRyrMLfAr/h9wF8sRVJyZDy6OHtVn6QGDXiv3BvAZVTY1Pu9xF7KdIdkYXbp4uayN0aDQQaAUJw==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@openai/codex@0.145.0-darwin-x64': - resolution: {integrity: sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA==} + '@openai/codex@0.147.0-darwin-x64': + resolution: {integrity: sha512-Tb8McE5SvJIH0Vs5R6sq7u+quiC931yan2KOOl6km1OdZ82+Wi7eF5XrSFPs5CF7xCgoIK4Vs+byMbT5hN+ZUw==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@openai/codex@0.145.0-linux-arm64': - resolution: {integrity: sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ==} + '@openai/codex@0.147.0-linux-arm64': + resolution: {integrity: sha512-SLC1JXw2TYfr/c3HhrJubyyLelq7vTOLWVmiThFA+z0+WgzCPmaseJ/kzDD3Gge/TO7fCnnj7UcPmC0d2c8XAg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@openai/codex@0.145.0-linux-x64': - resolution: {integrity: sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA==} + '@openai/codex@0.147.0-linux-x64': + resolution: {integrity: sha512-0W9MBxPpWW0cSkNqrTDN2jR7rzzT7oNMhQY5446lT2Lw5cz5yhDTck4Va9rjkQEm+HlFzP/dmEMSZbXfJsINmw==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@openai/codex@0.145.0-win32-arm64': - resolution: {integrity: sha512-sub61rjEFevi1i3Zx7nAd4JM5XxoNFqMqFc5LfTo2xSI8ixHjFvEYDFDXwXOftT04n3Ht1Wh271ioUZpDiEjEg==} + '@openai/codex@0.147.0-win32-arm64': + resolution: {integrity: sha512-e2ZstJ8zT8Rm1nvR7CUVO+Gr3cTChE41+VfOzGhynzDXEoW0wfbjUQbc2bWbh1arG94LMm4y3dqBtUIbSrfeGA==} engines: {node: '>=16'} cpu: [arm64] os: [win32] - '@openai/codex@0.145.0-win32-x64': - resolution: {integrity: sha512-u0h9lk094CaXRSqE34SBW2dRaQTPa6fASXqehczWH9QdsU62mBsiAgAdp6tCG4i+YzPmmhjD8FdXNnYGNmwuMg==} + '@openai/codex@0.147.0-win32-x64': + resolution: {integrity: sha512-oT7Ss5fAPf2fiWE9QNURqZcQGAAawSVxmIUdgPzckq4KFZAM+pRz9JbM4Rr498CjtbNgTOjWvDJ+DXvIBSfOPA==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -3316,9 +3481,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.61.1': - resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} - engines: {node: '>=18'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} hasBin: true '@preact/signals-core@1.14.4': @@ -4701,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': @@ -4737,32 +4902,32 @@ 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.0': - resolution: {integrity: sha512-kbZGdX+mysiY4xezhpcFEwzQ1zSXcMhSS3u09Qt8+w5XetCAMMSv/yAxzpi6z+YoH+EdQ53e1q3MFtPUkzkfUA==} + '@storybook/addon-docs@10.5.8': + resolution: {integrity: sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - storybook: ^10.5.0 + storybook: ^10.5.8 peerDependenciesMeta: '@types/react': optional: true - '@storybook/builder-vite@10.5.0': - resolution: {integrity: sha512-KXlifNIThDgS84KqVAJXyilool8OLTWp6DGoO9h5bHM2IPLe7UcdKfOzMUBkQ807mWBk4aW1yGEekj7kC2dvmg==} + '@storybook/builder-vite@10.5.8': + resolution: {integrity: sha512-UeRnn7yT55WmBlHNOQzLrvN7vsHEvVgIukhKDO+4cMbGXN87wZkbxhx6NstpuXRH8OxGqwKS0SZNVp+SC1ftLQ==} peerDependencies: - storybook: ^10.5.0 + storybook: ^10.5.8 vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@storybook/csf-plugin@10.5.0': - resolution: {integrity: sha512-R7VFw6FnDZTWct0ekOcFnTfDgdHnnAuQW+AbGG9A9IZPvyH9uLJivK7L86+r9MITRrO2+v81HaFDsT/OFk6ZkQ==} + '@storybook/csf-plugin@10.5.8': + resolution: {integrity: sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==} peerDependencies: esbuild: '*' rollup: '>=4.59.0' - storybook: ^10.5.0 + storybook: ^10.5.8 vite: '*' webpack: '*' peerDependenciesMeta: @@ -4783,40 +4948,40 @@ packages: peerDependencies: react: ^19.2.7 - '@storybook/react-dom-shim@10.5.0': - resolution: {integrity: sha512-GwGA6zDj4Cfw6vGsdfPKfF39L0W6solNbbnjdjGa57m2ooASkidLhrXo2wgC9wh3o/jcfonmYPDRGY6sEhGDtg==} + '@storybook/react-dom-shim@10.5.8': + resolution: {integrity: sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^19.2.7 react-dom: ^19.2.7 - storybook: ^10.5.0 + storybook: ^10.5.8 peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true - '@storybook/react-vite@10.5.0': - resolution: {integrity: sha512-d9n/I3pViscXpJYT9JHxcpxVSam14HsHOr2wBqTQTiRtaiH4xSsT00HTEGm6CEZTyq/npTJyV0Qday9XhrtiDQ==} + '@storybook/react-vite@10.5.8': + resolution: {integrity: sha512-ioMJGi4YzueGsJBlYio+2+UhfCFB9QV5Bs1lOilkek+a4BZgKJl0D1mVSJl6k96stQBPZmLgI9/l0hLVcUL6Kg==} peerDependencies: react: ^19.2.7 react-dom: ^19.2.7 - storybook: ^10.5.0 + storybook: ^10.5.8 typescript: '>= 4.9.x' vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: optional: true - '@storybook/react@10.5.0': - resolution: {integrity: sha512-2IhddiREy7NqAqnFsEOfW6HBG7azIcLxhRQYploSsaemOncR29xhzvAZsG+xlWgrtvxv4h3fYQTTR4TNn8CgPQ==} + '@storybook/react@10.5.8': + resolution: {integrity: sha512-6qqkmqX6imtL+0Z9Uan2tIfYivOI0FiVmWr0zpqqQR15AkJ18JfNcNTQoyjeAlCO0Kei56SWqnu2qLq52TYplg==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@types/react-dom': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^19.2.7 react-dom: ^19.2.7 - storybook: ^10.5.0 + storybook: ^10.5.8 typescript: '>= 4.9.x' peerDependenciesMeta: '@types/react': @@ -5194,6 +5359,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: @@ -5205,30 +5373,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==} @@ -5255,6 +5449,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + acpx@0.12.0: resolution: {integrity: sha512-APYpN04XFWrCGuSBvM4HTKWWFH8uSIuzc+qI7aCGeVdP9o4euZeBosFEkmNUHvBOop0XBemg6d8RsNvzXN3Mgw==} engines: {node: '>=22.13.0'} @@ -5378,8 +5577,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: @@ -5440,8 +5639,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.43: - resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + baseline-browser-mapping@2.11.15: + resolution: {integrity: sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==} engines: {node: '>=6.0.0'} hasBin: true @@ -5452,8 +5651,8 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} - better-auth@1.6.25: - resolution: {integrity: sha512-fvoq+oCO+FF5fpP3XfU7znRyGFpHB77UG2EyxsKNy+Cak7Q5pELu+auvvDveQbWQxcoKugZ7jYQQPFQLpUTGOw==} + better-auth@1.6.28: + resolution: {integrity: sha512-PW1y6mepLINbv04mimKbF3qjy4B/3ynG/Kc0jT3X+qepu1M+9UJJj3kS8parSnpT8cPEHWhu5sJCuL7iyMyddw==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -5514,8 +5713,8 @@ packages: vue: optional: true - better-call@1.3.7: - resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -5544,20 +5743,20 @@ packages: brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} - brace-expansion@2.1.2: - resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} - brace-expansion@5.0.7: - resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - browserslist@4.28.6: - resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -5604,8 +5803,8 @@ packages: caniuse-lite@1.0.30001770: resolution: {integrity: sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==} - caniuse-lite@1.0.30001805: - resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + caniuse-lite@1.0.30001809: + resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6012,8 +6211,8 @@ packages: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} - default-browser@5.5.0: - resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + default-browser@5.5.1: + resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} engines: {node: '>=18'} define-lazy-prop@3.0.0: @@ -6209,8 +6408,8 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} - electron-to-chromium@1.5.389: - resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} + electron-to-chromium@1.5.409: + resolution: {integrity: sha512-ChI4N44d0B4A6C8prnNjMOaGgE59fUyEVYcRYm2XEXIjMbbvF5i9UL1cblDbpGqiU0uS8FE8UcKxqZqTXdmzbQ==} embedded-postgres@18.1.0-beta.16: resolution: {integrity: sha512-TDp7Ld0h84x5fzIIZFyreYWqZrxUNjuXB6OxqJCmV6PodB2vzQ+1hlL6n4uK1de7bIAxYt5OkDykwcuIONQdQg==} @@ -6262,6 +6461,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'} @@ -6304,6 +6506,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'} @@ -6616,8 +6823,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==} @@ -6791,12 +6998,12 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.4: - resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} - jose@6.2.8: resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -6860,8 +7067,8 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - kysely@0.29.4: - resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} + kysely@0.29.5: + resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} engines: {node: '>=22.0.0'} layout-base@1.0.2: @@ -7237,8 +7444,8 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -7319,8 +7526,8 @@ packages: engines: {node: ^18 || >=20} hasBin: true - nanostores@1.4.1: - resolution: {integrity: sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==} + nanostores@1.5.0: + resolution: {integrity: sha512-E0SrU7uLV/k4E6YbBIpQNDnRj00vgE5wlutM1sYVSAXDtHQosiRCGS9eKMKyiGg4ChXfDiJmTB+NCR3yXr6UAw==} engines: {node: ^20.0.0 || >=22.0.0} napi-build-utils@2.0.0: @@ -7361,8 +7568,8 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - node-releases@2.0.51: - resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} nopt@5.0.0: @@ -7408,8 +7615,8 @@ 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==} + open@11.0.1: + resolution: {integrity: sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==} engines: {node: '>=20'} outvariant@1.4.0: @@ -7542,14 +7749,14 @@ packages: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true points-on-curve@0.2.0: @@ -7590,6 +7797,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + powershell-utils@0.2.0: + resolution: {integrity: sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==} + engines: {node: '>=20'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -7693,8 +7904,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 @@ -7751,15 +7962,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 @@ -7870,8 +8081,8 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rou3@0.7.12: - resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} roughjs@4.6.6: resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} @@ -8161,9 +8372,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==} @@ -8221,6 +8429,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'} @@ -8264,8 +8476,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 @@ -8355,6 +8567,12 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-browserslist-db@1.3.1: + resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + url-template@2.0.8: resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} @@ -8558,9 +8776,46 @@ packages: jsdom: optional: true - void-elements@3.1.0: - resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} - engines: {node: '>=0.10.0'} + 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 vscode-jsonrpc@9.0.1: resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} @@ -8616,8 +8871,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 @@ -8632,8 +8887,8 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - wsl-utils@0.3.1: - resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + wsl-utils@1.0.0: + resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} xml-name-validator@5.0.0: @@ -8704,12 +8959,12 @@ snapshots: - '@anthropic-ai/sdk' - '@modelcontextprotocol/sdk' - '@agentclientprotocol/codex-acp@1.1.7': + '@agentclientprotocol/codex-acp@1.2.0': dependencies: '@agentclientprotocol/sdk': 1.3.0(zod@4.4.3) - '@openai/codex': 0.145.0 + '@openai/codex': 0.147.0 diff: 9.0.0 - open: 11.0.0 + open: 11.0.1 vscode-jsonrpc: 9.0.1 zod: 4.4.3 @@ -8851,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': {} @@ -9055,14 +9310,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -9080,10 +9335,10 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -9100,7 +9355,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.6 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -9110,15 +9365,15 @@ snapshots: '@babel/helper-module-imports@7.28.6': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -9136,7 +9391,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -9162,15 +9417,15 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: @@ -9193,8 +9448,8 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@babel/traverse@7.29.0': dependencies: @@ -9208,14 +9463,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -9225,15 +9480,15 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@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 @@ -9242,7 +9497,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 @@ -9253,58 +9508,62 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)': + '@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)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.43.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.7(zod@4.4.3) - jose: 6.2.4 - kysely: 0.29.4 - nanostores: 1.4.1 + better-call: 1.4.0(zod@4.4.3) + jose: 6.2.9 + kysely: 0.29.5 + nanostores: 1.5.0 zod: 4.4.3 optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@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.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7))': + '@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))': dependencies: - '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@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 optionalDependencies: - drizzle-orm: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7) + 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) - '@better-auth/kysely-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-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)(kysely@0.29.5)': dependencies: - '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@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 optionalDependencies: - kysely: 0.29.4 + kysely: 0.29.5 - '@better-auth/memory-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/memory-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)': dependencies: - '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@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 - '@better-auth/mongo-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-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)': dependencies: - '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@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 - '@better-auth/prisma-adapter@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-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)': dependencies: - '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@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 - '@better-auth/telemetry@1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@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)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@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 '@better-fetch/fetch': 1.3.1 '@better-auth/utils@0.4.2': dependencies: - '@noble/hashes': 2.2.0 + '@noble/hashes': 2.3.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.3.0 '@better-fetch/fetch@1.3.1': {} @@ -9697,22 +9956,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) @@ -9721,11 +9980,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: @@ -9834,6 +10093,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 @@ -9846,6 +10108,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 @@ -9858,6 +10123,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 @@ -9870,6 +10138,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 @@ -9882,6 +10153,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 @@ -9894,6 +10168,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 @@ -9906,6 +10183,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 @@ -9918,6 +10198,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 @@ -9930,6 +10213,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 @@ -9942,6 +10228,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 @@ -9954,6 +10243,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 @@ -9966,6 +10258,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 @@ -9978,6 +10273,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 @@ -9990,6 +10288,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 @@ -10002,6 +10303,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 @@ -10014,6 +10318,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 @@ -10026,6 +10333,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 @@ -10035,6 +10345,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 @@ -10047,6 +10360,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 @@ -10056,6 +10372,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 @@ -10068,6 +10387,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 @@ -10077,6 +10399,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 @@ -10089,6 +10414,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 @@ -10101,6 +10429,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 @@ -10113,6 +10444,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 @@ -10125,9 +10459,12 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@exodus/bytes@1.15.0(@noble/hashes@2.2.0)': + '@esbuild/win32-x64@0.28.2': + optional: true + + '@exodus/bytes@1.15.0(@noble/hashes@2.3.0)': optionalDependencies: - '@noble/hashes': 2.2.0 + '@noble/hashes': 2.3.0 '@fastify/busboy@2.1.1': {} @@ -10286,11 +10623,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 @@ -10763,11 +11100,11 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@noble/ciphers@2.2.0': {} + '@noble/ciphers@2.3.0': {} '@noble/hashes@1.8.0': {} - '@noble/hashes@2.2.0': {} + '@noble/hashes@2.3.0': {} '@npmcli/fs@1.1.1': dependencies: @@ -10783,31 +11120,31 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} - '@openai/codex@0.145.0': + '@openai/codex@0.147.0': optionalDependencies: - '@openai/codex-darwin-arm64': '@openai/codex@0.145.0-darwin-arm64' - '@openai/codex-darwin-x64': '@openai/codex@0.145.0-darwin-x64' - '@openai/codex-linux-arm64': '@openai/codex@0.145.0-linux-arm64' - '@openai/codex-linux-x64': '@openai/codex@0.145.0-linux-x64' - '@openai/codex-win32-arm64': '@openai/codex@0.145.0-win32-arm64' - '@openai/codex-win32-x64': '@openai/codex@0.145.0-win32-x64' + '@openai/codex-darwin-arm64': '@openai/codex@0.147.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.147.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.147.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.147.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.147.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.147.0-win32-x64' - '@openai/codex@0.145.0-darwin-arm64': + '@openai/codex@0.147.0-darwin-arm64': optional: true - '@openai/codex@0.145.0-darwin-x64': + '@openai/codex@0.147.0-darwin-x64': optional: true - '@openai/codex@0.145.0-linux-arm64': + '@openai/codex@0.147.0-linux-arm64': optional: true - '@openai/codex@0.145.0-linux-x64': + '@openai/codex@0.147.0-linux-x64': optional: true - '@openai/codex@0.145.0-win32-arm64': + '@openai/codex@0.147.0-win32-arm64': optional: true - '@openai/codex@0.145.0-win32-x64': + '@openai/codex@0.147.0-win32-x64': optional: true '@opentelemetry/api@1.9.1': {} @@ -10974,9 +11311,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.61.1': + '@playwright/test@1.62.1': dependencies: - playwright: 1.61.1 + playwright: 1.62.1 '@preact/signals-core@1.14.4': {} @@ -12350,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 @@ -12393,18 +12730,18 @@ 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.0(@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.0(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.0(@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)) + '@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 react-dom: 19.2.7(react@19.2.7) storybook: 10.5.5(@types/react@19.2.17)(react@19.2.7) @@ -12418,25 +12755,25 @@ snapshots: - vite - webpack - '@storybook/builder-vite@10.5.0(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.0(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.0(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': {} @@ -12444,7 +12781,7 @@ snapshots: dependencies: react: 19.2.7 - '@storybook/react-dom-shim@10.5.0(@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))': + '@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))': dependencies: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -12453,12 +12790,12 @@ snapshots: '@types/react': 19.2.17 '@types/react-dom': 19.2.3(@types/react@19.2.17) - '@storybook/react-vite@10.5.0(@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.0(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/react': 10.5.0(@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) + '@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 react: 19.2.7 @@ -12467,7 +12804,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: @@ -12478,10 +12815,10 @@ snapshots: - supports-color - webpack - '@storybook/react@10.5.0(@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)': + '@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)': dependencies: '@storybook/global': 5.0.0 - '@storybook/react-dom-shim': 10.5.0(@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)) + '@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 react-docgen: 8.0.3 react-docgen-typescript: 2.4.0(typescript@5.9.3) @@ -12555,17 +12892,17 @@ 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.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': {} @@ -12889,7 +13226,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) @@ -12897,7 +13234,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 @@ -12918,29 +13255,46 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@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/expect@4.1.11': 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) + '@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@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@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: 7.3.1(@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@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@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@25.2.3)(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.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.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.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.12) '@vitest/pretty-format@3.2.4': dependencies: @@ -12950,11 +13304,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 @@ -12962,12 +13325,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 @@ -12980,6 +13352,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': {} @@ -13000,12 +13378,14 @@ snapshots: acorn@8.17.0: {} + acorn@8.18.0: {} + acpx@0.12.0(patch_hash=x3fethhotv43zektyl5prdwf54): dependencies: '@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 @@ -13113,7 +13493,7 @@ snapshots: atomic-sleep@1.0.0: {} - axe-core@4.12.1: {} + axe-core@4.13.0: {} b4a@1.8.1: {} @@ -13154,7 +13534,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.43: {} + baseline-browser-mapping@2.11.15: {} baseline-browser-mapping@2.9.19: {} @@ -13162,41 +13542,41 @@ snapshots: dependencies: tweetnacl: 0.14.5 - better-auth@1.6.25(@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.4)(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.2.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.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/drizzle-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@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.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7)) - '@better-auth/kysely-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.25(@better-auth/core@1.6.25(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@3.25.76))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@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)) + '@better-auth/kysely-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)(kysely@0.29.5) + '@better-auth/memory-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) + '@better-auth/mongo-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) + '@better-auth/prisma-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) + '@better-auth/telemetry': 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)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@noble/ciphers': 2.2.0 - '@noble/hashes': 2.2.0 - better-call: 1.3.7(zod@4.4.3) + '@noble/ciphers': 2.3.0 + '@noble/hashes': 2.3.0 + better-call: 1.4.0(zod@4.4.3) defu: 6.1.7 - jose: 6.2.4 - kysely: 0.29.4 - nanostores: 1.4.1 + jose: 6.2.9 + kysely: 0.29.5 + nanostores: 1.5.0 zod: 4.4.3 optionalDependencies: drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.1)(kysely@0.29.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7) + 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: 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.2.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' - better-call@1.3.7(zod@4.4.3): + better-call@1.4.0(zod@4.4.3): dependencies: - '@better-auth/utils': 0.4.2 + '@better-auth/utils': 0.5.0 '@better-fetch/fetch': 1.3.1 - rou3: 0.7.12 + rou3: 0.9.2 set-cookie-parser: 3.1.2 optionalDependencies: zod: 4.4.3 @@ -13241,11 +13621,11 @@ snapshots: concat-map: 0.0.1 optional: true - brace-expansion@2.1.2: + brace-expansion@2.1.4: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.7: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -13257,13 +13637,13 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) - browserslist@4.28.6: + browserslist@4.28.8: dependencies: - baseline-browser-mapping: 2.10.43 - caniuse-lite: 1.0.30001805 - electron-to-chromium: 1.5.389 - node-releases: 2.0.51 - update-browserslist-db: 1.2.3(browserslist@4.28.6) + baseline-browser-mapping: 2.11.15 + caniuse-lite: 1.0.30001809 + electron-to-chromium: 1.5.409 + node-releases: 2.0.53 + update-browserslist-db: 1.3.1(browserslist@4.28.8) buffer-equal-constant-time@1.0.1: {} @@ -13329,7 +13709,7 @@ snapshots: caniuse-lite@1.0.30001770: {} - caniuse-lite@1.0.30001805: {} + caniuse-lite@1.0.30001809: {} ccount@2.0.1: {} @@ -13702,10 +14082,10 @@ snapshots: data-uri-to-buffer@4.0.1: {} - data-urls@7.0.0(@noble/hashes@2.2.0): + data-urls@7.0.0(@noble/hashes@2.3.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.2.0) + whatwg-url: 16.0.1(@noble/hashes@2.3.0) transitivePeerDependencies: - '@noble/hashes' @@ -13737,7 +14117,7 @@ snapshots: default-browser-id@5.0.1: {} - default-browser@5.5.0: + default-browser@5.5.1: dependencies: bundle-name: 4.1.0 default-browser-id: 5.0.1 @@ -13812,13 +14192,13 @@ 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.4)(pg@8.18.0)(postgres@3.4.9)(sqlite3@5.1.7): + 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: '@electric-sql/pglite': 0.3.15 '@opentelemetry/api': 1.9.1 - kysely: 0.29.4 + kysely: 0.29.5 pg: 8.18.0 postgres: 3.4.9 sqlite3: 5.1.7 @@ -13839,7 +14219,7 @@ snapshots: electron-to-chromium@1.5.286: {} - electron-to-chromium@1.5.389: {} + electron-to-chromium@1.5.409: {} embedded-postgres@18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei): dependencies: @@ -13893,6 +14273,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 @@ -14036,6 +14418,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: {} @@ -14321,7 +14732,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.5 + minimatch: 10.2.6 minipass: 7.1.3 path-scurry: 2.0.2 @@ -14450,15 +14861,13 @@ snapshots: hono@4.13.2: {} - html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + html-encoding-sniffer@6.0.0(@noble/hashes@2.3.0): dependencies: - '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) + '@exodus/bytes': 1.15.0(@noble/hashes@2.3.0) 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: {} @@ -14618,10 +15027,10 @@ snapshots: jiti@2.7.0: {} - jose@6.2.4: {} - jose@6.2.8: {} + jose@6.2.9: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -14630,16 +15039,16 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@28.1.0(@noble/hashes@2.2.0): + jsdom@28.1.0(@noble/hashes@2.3.0): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 '@bramus/specificity': 2.4.2 - '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) + '@exodus/bytes': 1.15.0(@noble/hashes@2.3.0) cssstyle: 6.2.0 - data-urls: 7.0.0(@noble/hashes@2.2.0) + data-urls: 7.0.0(@noble/hashes@2.3.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + html-encoding-sniffer: 6.0.0(@noble/hashes@2.3.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 @@ -14651,7 +15060,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.2.0) + whatwg-url: 16.0.1(@noble/hashes@2.3.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -14695,7 +15104,7 @@ snapshots: kleur@4.1.5: {} - kysely@0.29.4: {} + kysely@0.29.5: {} layout-base@1.0.2: {} @@ -15361,9 +15770,9 @@ snapshots: min-indent@1.0.1: {} - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.9 minimatch@3.1.5: dependencies: @@ -15372,7 +15781,7 @@ snapshots: minimatch@9.0.9: dependencies: - brace-expansion: 2.1.2 + brace-expansion: 2.1.4 minimist@1.2.8: {} @@ -15445,7 +15854,7 @@ snapshots: nanoid@5.1.11: {} - nanostores@1.4.1: {} + nanostores@1.5.0: {} napi-build-utils@2.0.0: optional: true @@ -15492,7 +15901,7 @@ snapshots: node-releases@2.0.27: {} - node-releases@2.0.51: {} + node-releases@2.0.53: {} nopt@5.0.0: dependencies: @@ -15533,19 +15942,19 @@ 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: + open@11.0.1: dependencies: - default-browser: 5.5.0 + default-browser: 5.5.1 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 + powershell-utils: 0.2.0 + wsl-utils: 1.0.0 outvariant@1.4.0: {} @@ -15738,11 +16147,11 @@ snapshots: pkce-challenge@5.0.1: {} - playwright-core@1.61.1: {} + playwright-core@1.62.1: {} - playwright@1.61.1: + playwright@1.62.1: dependencies: - playwright-core: 1.61.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 @@ -15778,6 +16187,8 @@ snapshots: powershell-utils@0.1.0: {} + powershell-utils@0.2.0: {} + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -15936,8 +16347,8 @@ snapshots: react-docgen@8.0.3: dependencies: '@babel/core': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 '@types/doctrine': 0.0.9 @@ -15957,10 +16368,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) @@ -16016,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 @@ -16181,7 +16592,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.4 fsevents: 2.3.3 - rou3@0.7.12: {} + rou3@0.9.2: {} roughjs@4.6.6: dependencies: @@ -16460,7 +16871,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: @@ -16565,8 +16976,6 @@ snapshots: tailwind-merge@3.6.0: {} - tailwindcss@4.3.2: {} - tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -16643,6 +17052,8 @@ snapshots: tinyrainbow@3.1.0: {} + tinyrainbow@3.1.1: {} + tinyspy@4.0.4: {} tldts-core@7.0.26: {} @@ -16677,9 +17088,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 @@ -16776,7 +17187,7 @@ snapshots: unplugin@2.3.11: dependencies: '@jridgewell/remapping': 2.3.5 - acorn: 8.17.0 + acorn: 8.18.0 picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 @@ -16786,9 +17197,9 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - update-browserslist-db@1.2.3(browserslist@4.28.6): + update-browserslist-db@1.3.1(browserslist@4.28.8): dependencies: - browserslist: 4.28.6 + browserslist: 4.28.8 escalade: 3.2.0 picocolors: 1.1.1 @@ -16859,7 +17270,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) @@ -16872,9 +17283,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) @@ -16887,9 +17298,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) @@ -16902,12 +17313,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.2.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 @@ -16924,19 +17335,19 @@ 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 '@types/node': 22.20.1 - jsdom: 28.1.0(@noble/hashes@2.2.0) + jsdom: 28.1.0(@noble/hashes@2.3.0) transitivePeerDependencies: - msw - vitest@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)): + 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 @@ -16953,19 +17364,19 @@ 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 '@types/node': 22.20.1 - jsdom: 28.1.0(@noble/hashes@2.2.0) + jsdom: 28.1.0(@noble/hashes@2.3.0) transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@25.2.3)(jsdom@28.1.0(@noble/hashes@2.2.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 @@ -16982,16 +17393,43 @@ 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 '@types/node': 25.2.3 - jsdom: 28.1.0(@noble/hashes@2.2.0) + jsdom: 28.1.0(@noble/hashes@2.3.0) transitivePeerDependencies: - msw - void-elements@3.1.0: {} + 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.12)) + '@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.12) + 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 vscode-jsonrpc@9.0.1: {} @@ -17009,9 +17447,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1(@noble/hashes@2.2.0): + whatwg-url@16.0.1(@noble/hashes@2.3.0): dependencies: - '@exodus/bytes': 1.15.0(@noble/hashes@2.2.0) + '@exodus/bytes': 1.15.0(@noble/hashes@2.3.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -17045,13 +17483,13 @@ snapshots: wrappy@1.0.2: {} - ws@8.21.1: {} + ws@8.21.3: {} wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 - wsl-utils@0.3.1: + wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1 powershell-utils: 0.1.0 diff --git a/releases/beta/v2026.818.0-beta.1.md b/releases/beta/v2026.818.0-beta.1.md new file mode 100644 index 00000000000..21dcf4b6b67 --- /dev/null +++ b/releases/beta/v2026.818.0-beta.1.md @@ -0,0 +1,65 @@ +# Paperclip v2026.821.0 + +> Released: 2026-08-21 + +Paperclip v2026.821.0 is a fast follow to 2026.817.0 — and the first stable to walk the full canary → nightly → beta → stable happy path end to end, soaking as `2026.818.0-beta.1` before promotion. It carries 172 commits: chat-style tasks graduate from experiment to the default experience, managed runtime previews become reachable over Tailscale HTTPS, sandbox execution gains a verified provider capability contract, you can sign in to Claude and Codex from inside the product, large company imports survive dropped connections, and a large batch of long-standing community fixes lands. + +## Breaking Changes + +None of these require action for a typical install, but read through before upgrading: + +- **Chat-style tasks are the default.** The "Chat-Style Tasks" experiment toggle is gone; the task detail page is now the conversation view for everyone. A new "Classic Task Interface" preference (`enableClassicTaskInterface`, replacing `enableTaskChatRedesign` in instance settings) restores the pre-chat page — page-level header, plain comment thread, fixed Properties sidebar — but chat-only features are unavailable there. ([#11101](https://github.com/paperclipai/paperclip/pull/11101)) +- **Sandbox session-output streaming is now capability-driven, and three operator flags are gone.** The server's `streamAgentSessionOutput` setting and the Daytona plugin's `useSessions` and `useLogStream` keys no longer exist; the verified provider capability snapshot is the only streaming decision, with an automatic poll fallback. Saved configurations that still carry the removed keys load fine — the keys are simply inert. ([#11463](https://github.com/paperclipai/paperclip/pull/11463), [#11557](https://github.com/paperclipai/paperclip/pull/11557)) +- **Managed dev runtimes now default to Tailscale HTTPS previews.** When the host broker is available, eligible Paperclip-managed worktree runtimes are exposed as `tailscale_https` instead of loopback HTTP. Set `PAPERCLIP_MANAGED_RUNTIME_HTTPS=off` to keep the old behavior, or `force` to fail closed when the broker is missing. Managed runtime control routes also gained two new `409` responses (`workspace_runtime_control_in_progress`, `workspace_runtime_lease_conflict`) — callers that assumed a control always starts must handle a conflict. ([#11524](https://github.com/paperclipai/paperclip/pull/11524), [#11525](https://github.com/paperclipai/paperclip/pull/11525)) +- **Interaction resolver policy defaults to `anyone` for new interactions.** Every interaction kind now goes through one resolver-policy evaluator with recorded provenance, instead of per-route rules that blocked valid agents. Migration `0218` deliberately does **not** widen anything already pending: existing rows keep their old restrictions under new names (`board_or_agents` → `not_creator`, `board_only` → `human_only`). ([#11376](https://github.com/paperclipai/paperclip/pull/11376)) +- **The decision-training surfaces are removed from the Decisions desk** — the training library route, row actions, and drawer. The backend snapshot contract is unchanged and no data migration runs. ([#11225](https://github.com/paperclipai/paperclip/pull/11225)) + +## Highlights + +- **Chat-style tasks, now the default experience** — Last release's experimental conversation view is now how every task works, and it grew up on the way: cleaner message spacing and agent bubbles, searchable assignment and the sub-task tree in the properties pane, document comments docked in a side gutter, and a chat sidebar that surfaces every issue document and agent artifact. Blocked tasks show their blockers as links with live, ordered blocker work right in the thread, and an issue with a live run shows a calm in-flight notice instead of a false alarm. Onboarding is chat-first too: finishing the wizard drops you straight into your first task's conversation, where the agent greets you, asks clarifying questions, and proposes a plan you approve item by item. ([#11101](https://github.com/paperclipai/paperclip/pull/11101), [#11263](https://github.com/paperclipai/paperclip/pull/11263), [#11226](https://github.com/paperclipai/paperclip/pull/11226), [#11456](https://github.com/paperclipai/paperclip/pull/11456), [#11487](https://github.com/paperclipai/paperclip/pull/11487), [#11423](https://github.com/paperclipai/paperclip/pull/11423)) +- **HTTPS previews for managed runtimes, over Tailscale** — A managed runtime service used to listen on plain loopback HTTP, so an agent's branch preview was unreachable from a phone or a second computer. Paperclip can now expose it at a real HTTPS URL on your tailnet, built in three deliberate layers: a separate least-privilege host broker that authorizes every mapping (peer credentials, a fixed port range, protected-port denies, append-only audit), the exposure lifecycle with durable issue-owned runtime leases and bounded control recovery so a stranded control can never block the lane forever, and a single port-pair mediator so a start can never collide with or adopt another issue's service — every mismatch fails closed and names the conflict. ([#11524](https://github.com/paperclipai/paperclip/pull/11524), [#11525](https://github.com/paperclipai/paperclip/pull/11525), [#11526](https://github.com/paperclipai/paperclip/pull/11526)) +- **Sign in to Claude and Codex from inside Paperclip** — The new-agent page now offers Claude subscription login before you create the agent, on both Test lanes, with diagnostics kept in redacted server logs and login URLs normalized to allowlisted Anthropic hosts. Under the hood, a guarded setup-token flow drives `claude setup-token` through a sandbox pseudo-terminal with owner-bound sessions and one-time token delivery, and the Codex adapter gets an equivalent sandbox device-login that promotes the credential only after readiness checks and then deletes the temporary sandbox. Deployments behind a TLS-terminating platform edge (Railway, Render, Fly, …) can declare `CLAUDE_LOGIN_EDGE_TLS_TERMINATED=true` so the transport guard stops warning about a connection that is HTTPS to the user, and a promoted device-login credential now survives managed-home re-seeding. ([#11347](https://github.com/paperclipai/paperclip/pull/11347), [#11286](https://github.com/paperclipai/paperclip/pull/11286), [#11237](https://github.com/paperclipai/paperclip/pull/11237), [#11579](https://github.com/paperclipai/paperclip/pull/11579), [#11578](https://github.com/paperclipai/paperclip/pull/11578)) +- **A real capability contract for sandbox providers** — Last release introduced live output streaming and persistent sessions for Daytona; this release makes those behaviors provable instead of configured. Providers declare capabilities in their manifest, the live worker verifies them, and the server resolves the effective set fail-closed as the intersection of declared, verified, and configured — a declared capability can never grant a method the worker didn't prove. Streaming and persistent sessions now turn on wherever the verified snapshot allows, with an automatic poll fallback, and the three operator flags that used to gate them are gone (see Breaking Changes). ([#11463](https://github.com/paperclipai/paperclip/pull/11463), [#11557](https://github.com/paperclipai/paperclip/pull/11557)) +- **Chunked, resumable company imports** — Last release raised the import cap to 1 GB; this release makes the transfer worthy of it. A dropped connection at 90% used to start the whole upload over — imports now move as verified ~32 MB parts spooled to disk with a durable run ledger, so an interrupted transfer resumes from the parts already uploaded (across dropped connections, page refreshes, and server restarts) and peak upload memory drops to one part. The Import page and CLI gained the resumable upload and a chunked preview, bundles now preserve task timestamps and hierarchy through export/import, imported agents keep their own adapter instead of being coerced to the destination's CEO adapter, and freshly imported tasks stay quiescent under the productivity review sweep. ([#11223](https://github.com/paperclipai/paperclip/pull/11223), [#11224](https://github.com/paperclipai/paperclip/pull/11224), [#11193](https://github.com/paperclipai/paperclip/pull/11193), [#11192](https://github.com/paperclipai/paperclip/pull/11192), [#11191](https://github.com/paperclipai/paperclip/pull/11191)) +## Improvements + +- **Onboarding meets you where you are** — A company with no agent is sent into onboarding automatically, opening at the right step: the mission step is skipped when the company already has one, an existing mission is carried into the wizard, and the step is decided once instead of jumping under a mid-flow customer. The wizard's agent and connect-model steps were reworked for the chat-first flow, no longer keep an invisible disabled adapter selected, and the decorative panel follows your theme. ([#11352](https://github.com/paperclipai/paperclip/pull/11352), [#11416](https://github.com/paperclipai/paperclip/pull/11416), [#11429](https://github.com/paperclipai/paperclip/pull/11429), [#11371](https://github.com/paperclipai/paperclip/pull/11371), [#11379](https://github.com/paperclipai/paperclip/pull/11379)) +- **Paperclip Cloud onboarding seed** — An instance can now receive the mission, first agent, and first task collected during Paperclip Cloud signup and apply them at activation, so a seeded stack opens ready to work instead of asking again. ([#11098](https://github.com/paperclipai/paperclip/pull/11098)) +- **Managed sandbox deployments** — Tenants can edit environment variables on a platform-managed sandbox environment (and only that), and a managed-sandbox-only mode hides local execution entirely with run selection failing closed. Bundled providers' documented credential env vars (e.g. `DAYTONA_API_KEY`) now reach their plugin workers, and the synthetic "Local" default option respects the mode. ([#11200](https://github.com/paperclipai/paperclip/pull/11200), [#11244](https://github.com/paperclipai/paperclip/pull/11244)) +- **Archived work reopens in place** — An archived isolated execution workspace can be reopened where it was, including archived git worktrees for managed-checkout projects. Cold arrivals on an archived company's URL are bounced to a clear page with a one-click Unarchive. ([#11322](https://github.com/paperclipai/paperclip/pull/11322), [#11395](https://github.com/paperclipai/paperclip/pull/11395), [#11302](https://github.com/paperclipai/paperclip/pull/11302)) +- **Review governance** — Review-policy verdicts are serialized and transactional, a verdict can no longer bypass a policy by downgrading, the requester is persisted atomically, and the UI badges the review policy when it is what constrains approval. ([#11405](https://github.com/paperclipai/paperclip/pull/11405), [#10938](https://github.com/paperclipai/paperclip/pull/10938)) +- **Agents can look up secrets by name** — A read-only `/secrets/catalog` endpoint maps secret names to ids for agents wiring env bindings — no values, no provider config. Issue-document annotations are also included in agent review context, so feedback left on a document reaches the agent that acts on it. ([#9530](https://github.com/paperclipai/paperclip/pull/9530), [#11332](https://github.com/paperclipai/paperclip/pull/11332)) +- **Search that scales** — The parent-issue picker searches on the server instead of the loaded page, and the issues list API gained an `updatedSince` filter. ([#11334](https://github.com/paperclipai/paperclip/pull/11334), [#9050](https://github.com/paperclipai/paperclip/pull/9050)) +- **Observability** — Host-side `stage.sync` steps are instrumented and the agent process span is homed correctly, continuing last release's sandbox-startup tracing work. ([#11301](https://github.com/paperclipai/paperclip/pull/11301)) +- **See your channel with `paperclipai channels`** — the new command shows every release lane (stable, beta, nightly, canary), the version each currently resolves to, and which one your install follows. ([#11210](https://github.com/paperclipai/paperclip/pull/11210)) + +## Fixes + +- **Deployments behind an authenticating proxy work end to end** — The live-events WebSocket now authenticates cloud-proxied browsers (previously it could never connect through an auth-terminating proxy), the service worker's fetch fallback always answers with a real `Response` instead of a blank page, the web app manifest is fetched with credentials, and the UI survives first load against a cold backend. The archived-company selection ping-pong that could trap a session in a redirect loop is gone. ([#11290](https://github.com/paperclipai/paperclip/pull/11290), [#11292](https://github.com/paperclipai/paperclip/pull/11292), [#11245](https://github.com/paperclipai/paperclip/pull/11245), [#11246](https://github.com/paperclipai/paperclip/pull/11246), [#11300](https://github.com/paperclipai/paperclip/pull/11300)) +- **Everything is scoped to the signed-in account** — Invite membership checks, company selection, and query caches are all keyed to the account that is actually signed in, caches clear on sign-out, and the stored company survives a failed company request instead of being dropped. Onboarding drafts verify ownership against a list fetched this session and never restore wizard state for a company the user does not own. ([#11417](https://github.com/paperclipai/paperclip/pull/11417), [#11430](https://github.com/paperclipai/paperclip/pull/11430), [#11380](https://github.com/paperclipai/paperclip/pull/11380), [#11477](https://github.com/paperclipai/paperclip/pull/11477), [#11382](https://github.com/paperclipai/paperclip/pull/11382), [#11370](https://github.com/paperclipai/paperclip/pull/11370)) +- **Security hardening** — Tool-access routes no longer leak cross-tenant ID oracles, routine webhooks reject HMAC replays, routine detail responses exclude assignee configuration, page-publish uploader credentials are scoped to the publish helper, execution workspace summaries are isolated, only accessible workspace file links are highlighted, the heartbeat-fallback comment can never publish a raw transcript, and CLI guidance shown to users goes through the safe `npx` form. ([#9589](https://github.com/paperclipai/paperclip/pull/9589), [#9994](https://github.com/paperclipai/paperclip/pull/9994), [#9818](https://github.com/paperclipai/paperclip/pull/9818), [#10894](https://github.com/paperclipai/paperclip/pull/10894), [#10790](https://github.com/paperclipai/paperclip/pull/10790), [#11090](https://github.com/paperclipai/paperclip/pull/11090), [#10143](https://github.com/paperclipai/paperclip/pull/10143), [#11343](https://github.com/paperclipai/paperclip/pull/11343), [#11400](https://github.com/paperclipai/paperclip/pull/11400)) +- **Runs keep their context** — Agent sessions are reused across execution handoffs and kept across issue comments, comment-wake decisions read post-insert issue state, stale closure-comment wakeups on done issues stop, successful-run handoff recovery skips recovery-action-driven and plugin-managed runs, and ACP run lifecycle corrections settle failures, sync workspaces back, and clean up leases properly. ([#9917](https://github.com/paperclipai/paperclip/pull/9917), [#10111](https://github.com/paperclipai/paperclip/pull/10111), [#10068](https://github.com/paperclipai/paperclip/pull/10068), [#8656](https://github.com/paperclipai/paperclip/pull/8656), [#9010](https://github.com/paperclipai/paperclip/pull/9010), [#9047](https://github.com/paperclipai/paperclip/pull/9047), [#11454](https://github.com/paperclipai/paperclip/pull/11454)) +- **Sandbox plumbing resilience** — The sandbox stdin file race is closed with atomic writes and a fault-tolerant poller, stdin writes are ordered in the process-session bridge, the callback bridge poll loop has a per-iteration timeout and watchdog, bridge traffic forwards to the local listen origin, the agent-hire skill's routes pass the callback bridge, runs that hit a provider worker restart window retry instead of failing setup, Daytona liveness calls are bounded per call, and `syncOut` accepts bsdtar tarball listings. ([#11235](https://github.com/paperclipai/paperclip/pull/11235), [#11406](https://github.com/paperclipai/paperclip/pull/11406), [#11341](https://github.com/paperclipai/paperclip/pull/11341), [#10017](https://github.com/paperclipai/paperclip/pull/10017), [#8978](https://github.com/paperclipai/paperclip/pull/8978), [#10212](https://github.com/paperclipai/paperclip/pull/10212), [#11408](https://github.com/paperclipai/paperclip/pull/11408), [#11289](https://github.com/paperclipai/paperclip/pull/11289)) +- **Adapter reliability** — Claude local pipes the print prompt via stdin and no longer fails root-only permission skips; Codex local skips benign stderr warnings when deriving a fallback error; OpenCode retries its models preflight under transient contention and treats the availability probe as non-fatal; Grok reports real token usage and cost instead of hardcoded zeros; Cursor cloud drops an unreachable API callback; hiring onto an adapter the instance cannot run is refused; and adapters load on Windows (`pathToFileURL` before dynamic import). ([#9500](https://github.com/paperclipai/paperclip/pull/9500), [#9463](https://github.com/paperclipai/paperclip/pull/9463), [#10003](https://github.com/paperclipai/paperclip/pull/10003), [#9225](https://github.com/paperclipai/paperclip/pull/9225), [#10294](https://github.com/paperclipai/paperclip/pull/10294), [#10433](https://github.com/paperclipai/paperclip/pull/10433), [#8546](https://github.com/paperclipai/paperclip/pull/8546), [#10256](https://github.com/paperclipai/paperclip/pull/10256), [#4287](https://github.com/paperclipai/paperclip/pull/4287)) +- **Server correctness** — `DELETE /api/issues/:id` succeeds for issues with dependents (comments and read-state cascade; cost and finance records are kept with their issue reference cleared), non-UUID company refs return 404 instead of 500, cross-package Zod errors are recognized, wiring blockers in a PATCH no longer implicitly reopens a blocked issue, unsigned ask-first requests stay out of the review queue without being cancelled, the terminal workspace reaper stops starving on its oldest candidates, the agent run JWT default TTL is aligned to the documented 48h so suspended-laptop runs stop waking with an expired credential, an explicit `PAPERCLIP_API_URL` overrides the derived runtime URL in run env, base-URL ports are only rewritten for loopback hosts, and silent nonzero exits in the Hermes flow surface as failures. ([#11331](https://github.com/paperclipai/paperclip/pull/11331), [#9959](https://github.com/paperclipai/paperclip/pull/9959), [#10168](https://github.com/paperclipai/paperclip/pull/10168), [#10269](https://github.com/paperclipai/paperclip/pull/10269), [#11338](https://github.com/paperclipai/paperclip/pull/11338), [#11238](https://github.com/paperclipai/paperclip/pull/11238), [#10176](https://github.com/paperclipai/paperclip/pull/10176), [#10339](https://github.com/paperclipai/paperclip/pull/10339), [#10258](https://github.com/paperclipai/paperclip/pull/10258), [#10107](https://github.com/paperclipai/paperclip/pull/10107)) +- **Workspace Git scans are bounded** — One bounded scheduler now coalesces and caps every expensive workspace Git scan (a request storm once produced ~270 concurrent `git status` processes and starved health checks), and the browser only starts scans when the file panel is open and visible. ([#11572](https://github.com/paperclipai/paperclip/pull/11572)) +- **UI paper cuts** — The queued-message interrupt action is back, agent secret access moved to a searchable secrets tab, slash autocomplete scrolls inside dialogs, inbox archive gained an undo toast and folded subtasks are remembered, inbox routing honors the saved policy for explicit targets, board approval prose renders as markdown, create-dialog badges use the real issue prefix, issue-document deep links route correctly, truncated issue descriptions are flagged, generated invite links prefer the public base URL, selected run lifecycle and logs stay current, and the mobile task chat gained blocker actions, a composer aligned with the thread, and a new-task dialog that stays above the keyboard. ([#11374](https://github.com/paperclipai/paperclip/pull/11374), [#11283](https://github.com/paperclipai/paperclip/pull/11283), [#11222](https://github.com/paperclipai/paperclip/pull/11222), [#11220](https://github.com/paperclipai/paperclip/pull/11220), [#11069](https://github.com/paperclipai/paperclip/pull/11069), [#11221](https://github.com/paperclipai/paperclip/pull/11221), [#9817](https://github.com/paperclipai/paperclip/pull/9817), [#8550](https://github.com/paperclipai/paperclip/pull/8550), [#11551](https://github.com/paperclipai/paperclip/pull/11551), [#4771](https://github.com/paperclipai/paperclip/pull/4771), [#7619](https://github.com/paperclipai/paperclip/pull/7619), [#10169](https://github.com/paperclipai/paperclip/pull/10169), [#11282](https://github.com/paperclipai/paperclip/pull/11282), [#11296](https://github.com/paperclipai/paperclip/pull/11296), [#11281](https://github.com/paperclipai/paperclip/pull/11281)) +- **Odds and ends** — Skill sync refreshes project folders in place instead of replacing them, issue writes through board keys work regardless of assignee, environment-secret bindings resolve their company context on first save, and worktree-mode dev instances warn when the embedded-Postgres data directory sits in the OS temp dir. ([#11066](https://github.com/paperclipai/paperclip/pull/11066), [#9025](https://github.com/paperclipai/paperclip/pull/9025), [#11291](https://github.com/paperclipai/paperclip/pull/11291), [#8283](https://github.com/paperclipai/paperclip/pull/8283)) + +## Upgrade Guide + +- **Migrations**: this release adds 11 database migrations (`0212` through `0222`), covering the one-per-company onboarding first task (`0212`), resumable company transfer runs (`0213`), adapter device-login sessions (`0214`, `0215`), Cloud onboarding seeds (`0216`), issue-deletion foreign-key policies (`0217`), interaction resolver-policy defaults and provenance (`0218`), runtime exposure state (`0219`), execution-workspace runtime leases (`0220`), Claude setup-token sessions (`0221`), and environment-lease references that survive environment deletion (`0222`). Migrations run automatically on startup — no manual action required. Nothing drops a table or deletes data; note that `0218` rewrites resolver-policy values on existing pending interactions (conservatively — see Breaking Changes) and `0217`/`0222` change what happens to dependent rows on delete. +- **New optional environment variables** (all have sensible defaults; set only if you need them): + - `PAPERCLIP_MANAGED_RUNTIME_HTTPS` — `auto` (default), `off`, or `force`; controls the Tailscale HTTPS default for managed runtime previews (see Breaking Changes). `PAPERCLIP_TAILSCALE_BROKER_SOCKET` and `PAPERCLIP_TAILSCALE_DNS_NAME` override the broker socket path and the tailnet DNS name; `PAPERCLIP_VITE_HMR_PROTOCOL` (`ws`/`wss`) pins the dev-preview HMR protocol. ([#11524](https://github.com/paperclipai/paperclip/pull/11524), [#11525](https://github.com/paperclipai/paperclip/pull/11525)) + - `CLAUDE_LOGIN_TRUSTED_PROXIES` — allowlisted proxy peers for the Claude login transport guard; `CLAUDE_LOGIN_EDGE_TLS_TERMINATED=true` — declare platform-edge TLS termination on managed platforms where the proxy addresses aren't knowable. ([#11286](https://github.com/paperclipai/paperclip/pull/11286), [#11579](https://github.com/paperclipai/paperclip/pull/11579)) + - `PAPERCLIP_ONBOARDING_SEED_ADAPTER_TYPE` — adapter type for the agent created by a Cloud onboarding seed. ([#11098](https://github.com/paperclipai/paperclip/pull/11098)) + - `SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR` — where durable sandbox cleanup records are spooled; `PAPERCLIP_PROCESS_SESSION_STDIN_MAX_RETRIES` — retry ceiling for the sandbox stdin poller. ([#11347](https://github.com/paperclipai/paperclip/pull/11347), [#11235](https://github.com/paperclipai/paperclip/pull/11235)) +- **Removed configuration keys**: `streamAgentSessionOutput` (server) and `useSessions` / `useLogStream` (Daytona plugin) no longer do anything — remove them from saved configs at your leisure; they load but are inert. ([#11557](https://github.com/paperclipai/paperclip/pull/11557)) +- **Agent run JWT TTL**: the server default now matches the documented 48h (`PAPERCLIP_AGENT_JWT_TTL_SECONDS=172800`). If you relied on the old implicit 1h expiry, set the variable explicitly. ([#10176](https://github.com/paperclipai/paperclip/pull/10176)) +- **HTTPS previews**: to use them, install Tailscale on the host and run the new broker (see the `@paperclipai/tailscale-https-broker` README); to opt out, set `PAPERCLIP_MANAGED_RUNTIME_HTTPS=off`. Nothing else changes for unmanaged or custom services. + +## Contributors + +This release has 172 commits from 32 contributors. Thank you to everyone who contributed to this release! + +@0xopaque, @adamteale, @ApolinarioRatio, @austinpilz, @christianlappin, @cucurigoo, @dmndbrp-oss, @dylanroy, @edgardfrz, @fronc, @futhgar, @panbanda, @raviy06, @samrusani, @santastabber, @sauerdaniel, @seb-veto, @Sergio-LPA, @stubbi, @ulisavo, @vmarchaud, @webdevdot diff --git a/scripts/__tests__/provision-worktree-self-heal.test.mjs b/scripts/__tests__/provision-worktree-self-heal.test.mjs index 606a24edc2d..91aa420358d 100644 --- a/scripts/__tests__/provision-worktree-self-heal.test.mjs +++ b/scripts/__tests__/provision-worktree-self-heal.test.mjs @@ -37,6 +37,9 @@ test.after(() => { */ function makeBaseWorkspace({ helpExit, initExit, ensureExit = 0 }) { const baseCwd = makeTempDir("paperclip-provision-base-"); + fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n"); + fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n"); const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs"); const entryPath = path.join(baseCwd, "cli", "src", "index.ts"); fs.mkdirSync(path.dirname(runnerPath), { recursive: true }); @@ -68,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); @@ -91,6 +108,8 @@ function runProvision(baseCwd, { pathPrefix } = {}) { 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", }, }); return { result, worktreeCwd, worktreesHome }; @@ -109,6 +128,8 @@ function runRuntimeProvision(baseCwd, worktreeCwd) { PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-runtime-test", PAPERCLIP_WORKTREES_DIR: worktreesHome, PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"), + PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1", + PAPERCLIP_COMPANY_ID: "company-1", }, }); } @@ -137,7 +158,10 @@ test("uses the base CLI when its import graph boots", () => { assert.equal(result.status, 0, result.stderr); const config = readWorktreeConfig(worktreeCwd); assert.equal(config.$meta.source, "fake-cli"); - assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); + assert.equal( + JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, + "pending", + ); const initInvocation = readCliInvocations(baseCwd).find( (args) => args[0] === "worktree" && args[1] === "init", ); @@ -166,7 +190,49 @@ test("falls back to an isolated config when the base CLI cannot boot", () => { ); const env = fs.readFileSync(path.join(worktreeCwd, ".paperclip", ".env"), "utf8"); assert.match(env, /PAPERCLIP_IN_WORKTREE=true/); - assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); + assert.equal( + JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state, + "pending", + ); +}); + +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) => { @@ -181,6 +247,9 @@ test("repairs an unhealthy base install under the lock and then uses the CLI", ( // The CLI's health is controlled by a flag file, and a fake `pnpm install` // creates that flag — modeling a forced reinstall that relinks the store. const baseCwd = makeTempDir("paperclip-provision-repair-base-"); + fs.mkdirSync(path.join(baseCwd, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(baseCwd, ".paperclip", "config.json"), "{}\n"); + fs.writeFileSync(path.join(baseCwd, ".paperclip", ".env"), "PAPERCLIP_INSTANCE_ID=base-source\n"); const healthFlag = path.join(baseCwd, "cli-healthy.flag"); const runnerPath = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs"); const entryPath = path.join(baseCwd, "cli", "src", "index.ts"); @@ -260,23 +329,72 @@ 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) .filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded"); assert.equal(ensureCallsAfterFirst.length, 1); assert.ok(ensureCallsAfterFirst[0].includes("--config")); - assert.ok(ensureCallsAfterFirst[0].includes("--from-config")); + assert.ok(!ensureCallsAfterFirst[0].includes("--from-config")); 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-"); @@ -290,3 +408,22 @@ test("runtime provisioning leaves seed-pending in place when ensure-seeded fails assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending"))); assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete"))); }); + +test("runtime provisioning does not trust a truncated verified manifest", () => { + const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 }); + const worktreeCwd = makeTempDir("paperclip-provision-runtime-truncated-"); + fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n"); + fs.writeFileSync( + path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), + JSON.stringify({ version: 2, state: "verified" }), + ); + + const result = runRuntimeProvision(baseCwd, worktreeCwd); + assert.equal(result.status, 4, result.stderr); + assert.equal( + readCliInvocations(baseCwd) + .filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length, + 1, + ); +}); diff --git a/scripts/draft-stable-notes.sh b/scripts/draft-stable-notes.sh index 1ba75033d7d..5bd42e9ba11 100755 --- a/scripts/draft-stable-notes.sh +++ b/scripts/draft-stable-notes.sh @@ -70,28 +70,54 @@ if ! git -C "$repo_dir" rev-parse --verify "refs/tags/${beta_tag}" >/dev/null 2> release_fail "beta tag ${beta_tag} does not exist. Draft notes are generated from a published beta." fi source_sha="$(git -C "$repo_dir" rev-parse "${beta_tag}^{commit}")" +source_short="$(git -C "$repo_dir" rev-parse --short "$source_sha")" if [ -z "$out_file" ]; then out_file="${repo_dir}/releases/beta/v${beta_version}.md" fi -# Range start: the newest stable tag reachable from the source commit — -# not the newest by version, which can sit on a divergent lineage (a -# stable cut from a candidate branch, or a source that predates it) and -# would produce an empty or wrong range. Before the first reachable -# stable, fall back to the nearest beta tag strictly before the source; -# with no marker at all, cover the source commit's full history. -range_start="$(git -C "$repo_dir" describe --tags --match 'v[0-9]*' --abbrev=0 "$source_sha" 2>/dev/null || true)" -range_label="$range_start" +# Range start: walk stable tags newest-first and take the first whose +# merge-base with the source is a proper ancestor of the source. A stable +# cut from a candidate branch is not itself an ancestor of master, but +# its merge-base with the source is the promoted source commit — exactly +# the point the shipped stable's content diverges from. A stable that +# already contains the source (promoting an older commit) is skipped so +# the range never collapses to empty. Before the first stable, fall back +# to the nearest beta tag strictly before the source; with no marker at +# all, cover the source commit's full history. +range_start="" +range_label="" +range_cmd_start="" +while IFS= read -r stable_tag; do + [ -n "$stable_tag" ] || continue + mb="$(git -C "$repo_dir" merge-base "$stable_tag" "$source_sha" 2>/dev/null || true)" + [ -n "$mb" ] || continue + if [ "$mb" != "$source_sha" ]; then + range_start="$mb" + if [ "$mb" = "$(git -C "$repo_dir" rev-parse "${stable_tag}^{commit}")" ]; then + range_label="$stable_tag" + range_cmd_start="$stable_tag" + else + # rev-parse --short picks an abbreviation that is unambiguous in + # this repository, unlike a fixed nine-character truncation. + mb_short="$(git -C "$repo_dir" rev-parse --short "$mb")" + range_label="${stable_tag} (merge-base ${mb_short})" + range_cmd_start="$mb_short" + fi + break + fi +done < <(git -C "$repo_dir" tag -l 'v[0-9]*' --sort=-v:refname) if [ -z "$range_start" ]; then range_start="$(git -C "$repo_dir" describe --tags --match 'beta/v*' --abbrev=0 "${source_sha}^" 2>/dev/null || true)" range_label="$range_start" + range_cmd_start="$range_start" fi if [ -n "$range_start" ]; then range="${range_start}..${source_sha}" else range="$source_sha" range_label="the beginning of history" + range_cmd_start="the beginning of history" release_info "No stable or prior beta tag found; drafting from full history." fi @@ -114,7 +140,7 @@ conventional='^(feat|fix)(\([^)]*\))?!?: ' mkdir -p "$(dirname "$out_file")" { printf '# Paperclip stable draft — from beta %s\n\n' "$beta_version" - printf '> Auto-generated at beta publish from `git log %s..%s`.\n' "${range_label}" "${source_sha:0:9}" + printf '> Auto-generated at beta publish from `git log %s..%s` (baseline: %s).\n' "${range_cmd_start}" "${source_short}" "${range_label}" printf '> Edit freely during the soak: rewrite for release-notes voice,\n' printf '> fold noise, and call out anything a self-hoster must act on.\n' printf '> The stable promotion reads this file from master and publishes\n' diff --git a/scripts/draft-stable-notes.test.mjs b/scripts/draft-stable-notes.test.mjs index c5bb01dfc0f..53479a87739 100644 --- a/scripts/draft-stable-notes.test.mjs +++ b/scripts/draft-stable-notes.test.mjs @@ -79,6 +79,26 @@ test("uses the nearest ancestor stable tag, not the newest by version", () => { assert.doesNotMatch(body, /new work/); }); +test("starts from the merge-base when the last stable was cut from a candidate branch", () => { + const dir = makeFixtureRepo(); + commit(dir, "feat: shipped in the stable (#1)"); + git(dir, "checkout", "-q", "-b", "candidate"); + commit(dir, "docs: release notes only (#2)"); + git(dir, "tag", "v2026.200.0"); + git(dir, "checkout", "-q", "master"); + commit(dir, "feat: next-release work (#3)"); + git(dir, "tag", "beta/v2026.201.0-beta.0"); + + // The stable tag sits on the unmerged candidate branch, so it is not an + // ancestor of the beta source. The range must start at its merge-base + // with the source (the promoted commit), not fall back past it. + const { body } = runDraft(dir, "2026.201.0-beta.0"); + + assert.match(body, /- feat: next-release work \(#3\)/); + assert.doesNotMatch(body, /shipped in the stable/); + assert.doesNotMatch(body, /release notes only/); +}); + test("falls back to the previous beta tag when no stable tag exists", () => { const dir = makeFixtureRepo(); commit(dir, "feat: first-train work (#1)"); diff --git a/scripts/provision-worktree-runtime.sh b/scripts/provision-worktree-runtime.sh index 539713fa096..b9710344c5c 100755 --- a/scripts/provision-worktree-runtime.sh +++ b/scripts/provision-worktree-runtime.sh @@ -3,12 +3,9 @@ set -euo pipefail base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}" worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" -paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}" -paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" paperclip_dir="$worktree_cwd/.paperclip" worktree_config_path="$paperclip_dir/config.json" -seed_pending_marker_path="$paperclip_dir/seed-pending" -seed_complete_marker_path="$paperclip_dir/seed-complete" +seed_manifest_path="$paperclip_dir/seed-manifest.json" if [[ ! -d "$base_cwd" ]]; then echo "Base workspace does not exist: $base_cwd" >&2 @@ -20,30 +17,59 @@ if [[ ! -d "$worktree_cwd" ]]; then exit 1 fi -if [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then - echo "Worktree database is already seeded; skipping runtime provisioning." >&2 - exit 0 +if [[ -e "$seed_manifest_path" ]]; then + seed_manifest_state="$(SEED_MANIFEST_PATH="$seed_manifest_path" node <<'EOF' +const fs = require("node:fs"); +try { + const value = JSON.parse(fs.readFileSync(process.env.SEED_MANIFEST_PATH, "utf8")); + const complete = value?.version === 2 + && value?.state === "verified" + && value?.phase === "complete" + && typeof value?.source?.instanceId === "string" && value.source.instanceId.length > 0 + && typeof value?.source?.configPath === "string" && value.source.configPath.length > 0 + && (value?.seedMode === "minimal" || value?.seedMode === "full") + && typeof value?.snapshotAt === "string" && value.snapshotAt.length > 0 + && typeof value?.migrationRevision === "string" && value.migrationRevision.length > 0 + && typeof value?.targetInstanceId === "string" && value.targetInstanceId.length > 0 + && typeof value?.attemptId === "string" && value.attemptId.length > 0 + && typeof value?.startedAt === "string" + && typeof value?.finishedAt === "string" + && Array.isArray(value?.diagnostics) + && value.diagnostics.some((entry) => entry?.phase === "complete" && entry?.status === "succeeded" && typeof entry?.at === "string"); + process.stdout.write(complete ? "verified" : "incomplete"); +} catch { + process.stdout.write("invalid"); +} +EOF +)" + if [[ "$seed_manifest_state" == "verified" ]]; then + echo "Worktree database has a verified seed manifest; skipping runtime provisioning." >&2 + exit 0 + fi 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 -source_config_path="${PAPERCLIP_CONFIG:-}" -if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then - source_config_path="$base_cwd/.paperclip/config.json" -fi -if [[ -z "$source_config_path" ]]; then - source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json" -fi -source_config_args=(--from-config "$source_config_path") -if [[ "$source_config_path" == "$worktree_config_path" ]]; then - # A human may invoke this after sourcing `worktree env`, which points - # PAPERCLIP_CONFIG at the target. In that case the CLI reads the original - # source config from the seed-pending marker instead. - source_config_args=() -fi +# The CLI derives the source from PAPERCLIP_WORKSPACE_BASE_CWD, which the +# control plane injects from the registered project-workspace row. The seed +# manifest is diagnostic evidence only and must never choose the clone source. +source_config_args=() base_cli_runner_path="$base_cwd/cli/node_modules/tsx/dist/cli.mjs" base_cli_entry_path="$base_cwd/cli/src/index.ts" diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index b5b283a1343..63fa5703555 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -8,6 +8,7 @@ paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" paperclip_dir="$worktree_cwd/.paperclip" worktree_config_path="$paperclip_dir/config.json" worktree_env_path="$paperclip_dir/.env" +seed_manifest_path="$paperclip_dir/seed-manifest.json" seed_pending_marker_path="$paperclip_dir/seed-pending" seed_complete_marker_path="$paperclip_dir/seed-complete" worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}" @@ -39,12 +40,16 @@ if [[ ! -d "$worktree_cwd" ]]; then exit 1 fi -source_config_path="${PAPERCLIP_CONFIG:-}" -if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then - source_config_path="$base_cwd/.paperclip/config.json" +canonical_base_cwd="$(cd "$base_cwd" && pwd -P)" +source_config_path="$canonical_base_cwd/.paperclip/config.json" +if [[ ! -f "$source_config_path" || -L "$source_config_path" ]]; then + echo "Registered base project workspace has no canonical Paperclip config: $source_config_path" >&2 + exit 1 fi -if [[ -z "$source_config_path" ]]; then - source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json" +canonical_source_dir="$(cd "$(dirname "$source_config_path")" && pwd -P)" +if [[ "$canonical_source_dir/config.json" != "$source_config_path" ]]; then + echo "Registered base project workspace Paperclip config uses a symlink alias: $source_config_path" >&2 + exit 1 fi source_env_path="$(dirname "$source_config_path")/.env" @@ -237,25 +242,93 @@ for (const rawValue of runtimePaths) { EOF } -write_seed_pending_marker() { - SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \ - SEED_COMPLETE_MARKER_PATH="$seed_complete_marker_path" \ +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" \ + SEED_COMPLETE_MARKER_PATH="$seed_complete_marker_path" \ + SOURCE_CONFIG_PATH="$source_config_path" \ + TARGET_INSTANCE_ID="$worktree_instance_id" \ + node <<'EOF' +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const manifestPath = process.env.SEED_MANIFEST_PATH; const pendingPath = process.env.SEED_PENDING_MARKER_PATH; const completePath = process.env.SEED_COMPLETE_MARKER_PATH; +const sourceConfigPath = path.resolve(process.env.SOURCE_CONFIG_PATH); +const sourceEnvPath = path.join(path.dirname(sourceConfigPath), ".env"); +let sourceInstanceId = path.basename(path.dirname(sourceConfigPath)); +if (fs.existsSync(sourceEnvPath)) { + const match = fs.readFileSync(sourceEnvPath, "utf8").match(/^\s*(?:export\s+)?PAPERCLIP_INSTANCE_ID\s*=\s*["']?([^\s"'#]+)["']?/m); + if (match?.[1]) sourceInstanceId = match[1]; +} fs.rmSync(completePath, { force: true }); +fs.rmSync(pendingPath, { force: true }); +const at = new Date().toISOString(); fs.writeFileSync( - pendingPath, + manifestPath, `${JSON.stringify({ - version: 1, - state: "pending", - sourceConfigPath: path.resolve(process.env.SOURCE_CONFIG_PATH), + version: 2, + source: { + instanceId: sourceInstanceId, + configPath: sourceConfigPath, + }, + snapshotAt: null, seedMode: "minimal", - createdAt: new Date().toISOString(), + migrationRevision: null, + targetInstanceId: process.env.TARGET_INSTANCE_ID, + phase: "pending", + state: "pending", + attemptId: crypto.randomUUID(), + startedAt: null, + finishedAt: null, + diagnostics: [{ phase: "pending", status: "succeeded", at }], }, null, 2)}\n`, { mode: 0o600 }, ); @@ -551,8 +624,14 @@ else created_worktree_config=1 fi -if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then - write_seed_pending_marker +# 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 list_base_node_modules_paths() { diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index 4f7af35bd46..e77a94c30cc 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -73,6 +73,7 @@ const serializedServerVitestArgs = [ "--no-file-parallelism", "--maxWorkers=1", ]; +const sourceOnlyVitestArgs = ["--exclude", "**/dist/**"]; function walk(dir) { const entries = readdirSync(dir); @@ -283,7 +284,7 @@ function runVitest(args, label) { }; mkdirSync(env.PAPERCLIP_HOME, { recursive: true }); mkdirSync(env.TMPDIR, { recursive: true }); - const result = spawnSync("pnpm", ["exec", "vitest", "run", ...args], { + const result = spawnSync("pnpm", ["exec", "vitest", "run", ...sourceOnlyVitestArgs, ...args], { cwd: repoRoot, env, stdio: "inherit", diff --git a/server/package.json b/server/package.json index f27be5c0f4e..834f95292ac 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:*", @@ -62,7 +62,7 @@ "@paperclipai/hermes-paperclip-adapter": "workspace:*", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", - "better-auth": "1.6.25", + "better-auth": "1.6.28", "chokidar": "^4.0.3", "detect-port": "^2.1.0", "dompurify": "^3.4.13", @@ -72,13 +72,13 @@ "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", "sharp": "^0.35.3", "ssh2": "^1.17.0", - "ws": "^8.21.1", + "ws": "^8.21.3", "zod": "^3.24.2" }, "devDependencies": { @@ -93,7 +93,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" 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/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts index f3a29440ebd..5dd179edb58 100644 --- a/server/src/__tests__/dev-runner-worktree.test.ts +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -36,6 +36,36 @@ describe("dev-runner worktree env bootstrap", () => { expect(isWorktreeSeedPending(root)).toBe(false); }); + it("guards every manifest state except a complete verified manifest", () => { + const root = createTempRoot("paperclip-dev-runner-seed-manifest-"); + const manifestPath = path.join(root, ".paperclip", "seed-manifest.json"); + fs.mkdirSync(path.dirname(manifestPath), { recursive: true }); + fs.writeFileSync(manifestPath, JSON.stringify({ version: 2, state: "failed" }), "utf8"); + expect(isWorktreeSeedPending(root)).toBe(true); + + fs.writeFileSync(manifestPath, JSON.stringify({ version: 2, state: "verified" }), "utf8"); + expect(isWorktreeSeedPending(root)).toBe(true); + + fs.writeFileSync(manifestPath, JSON.stringify({ + version: 2, + source: { instanceId: "source", configPath: "/source/config.json" }, + snapshotAt: "2026-08-18T00:00:00.000Z", + seedMode: "minimal", + migrationRevision: "0001", + targetInstanceId: "target", + phase: "complete", + state: "verified", + attemptId: "attempt", + startedAt: "2026-08-18T00:00:00.000Z", + finishedAt: "2026-08-18T00:01:00.000Z", + diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }], + }), "utf8"); + expect(isWorktreeSeedPending(root)).toBe(false); + + fs.writeFileSync(manifestPath, "not-json", "utf8"); + expect(isWorktreeSeedPending(root)).toBe(true); + }); + it("detects linked git worktrees from .git files", () => { const root = createTempRoot("paperclip-dev-runner-worktree-"); fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); diff --git a/server/src/__tests__/embedded-postgres-supervisor.test.ts b/server/src/__tests__/embedded-postgres-supervisor.test.ts new file mode 100644 index 00000000000..6f1fcee809c --- /dev/null +++ b/server/src/__tests__/embedded-postgres-supervisor.test.ts @@ -0,0 +1,61 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; +import { createEmbeddedPostgresSupervisor, type SupervisedEmbeddedPostgres } from "../embedded-postgres-supervisor.js"; + +function createInstance(startError?: Error) { + const process = new EventEmitter(); + const instance: SupervisedEmbeddedPostgres = { + process, + start: vi.fn(async () => { if (startError) throw startError; }), + stop: vi.fn(async () => undefined), + }; + return { instance, process }; +} + +describe("embedded PostgreSQL supervisor", () => { + it("restarts PostgreSQL after its managed child exits unexpectedly", async () => { + const initial = createInstance(); + const replacement = createInstance(); + const onRestarted = vi.fn(); + const supervisor = createEmbeddedPostgresSupervisor({ + initialInstance: initial.instance, + createInstance: () => replacement.instance, + restartDelaysMs: [0], + onRestarted, + }); + initial.process.emit("exit", 137, "SIGKILL"); + await supervisor.waitForRecovery(); + expect(replacement.instance.start).toHaveBeenCalledOnce(); + expect(supervisor.current()).toBe(replacement.instance); + expect(onRestarted).toHaveBeenCalledWith(1); + }); + + it("does not restart PostgreSQL during orderly shutdown", async () => { + const initial = createInstance(); + const createReplacement = vi.fn(() => createInstance().instance); + const supervisor = createEmbeddedPostgresSupervisor({ + initialInstance: initial.instance, + createInstance: createReplacement, + restartDelaysMs: [0], + }); + await supervisor.shutdown(); + expect(initial.instance.stop).toHaveBeenCalledOnce(); + expect(createReplacement).not.toHaveBeenCalled(); + }); + + it("bounds recovery attempts and reports the final failure", async () => { + const initial = createInstance(); + const failures = [new Error("first"), new Error("second"), new Error("third")]; + const onRecoveryExhausted = vi.fn(); + const supervisor = createEmbeddedPostgresSupervisor({ + initialInstance: initial.instance, + createInstance: () => createInstance(failures.shift()).instance, + restartDelaysMs: [0, 0, 0], + onRecoveryExhausted, + }); + initial.process.emit("exit", 1, null); + await supervisor.waitForRecovery(); + expect(onRecoveryExhausted).toHaveBeenCalledOnce(); + expect(onRecoveryExhausted).toHaveBeenCalledWith(expect.objectContaining({ message: "third" })); + }); +}); 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/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index d07e20f224e..42d8420c7ff 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -19,6 +19,10 @@ 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, + duplexCommandStream: false, }; // A snapshot that grants every capability. A test overrides one flag to prove @@ -30,6 +34,8 @@ const FULL_GRANT: EffectiveSandboxCapabilities = { persistentProcessSessions: true, independentControlCommands: true, incrementalSessionOutput: true, + concurrentSyncOperations: true, + duplexCommandStream: true, }; // Build a sandbox execution target with a fixed snapshot and a fixed @@ -185,6 +191,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__/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__/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/__tests__/execution-workspace-runtime-control-conflict.test.ts b/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts index 3999299f586..c733af6a365 100644 --- a/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts +++ b/server/src/__tests__/execution-workspace-runtime-control-conflict.test.ts @@ -1,3 +1,8 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -24,11 +29,18 @@ const mockStartRuntimeServices = vi.hoisted(() => vi.fn()); const mockStopRuntimeServicesForExecutionWorkspace = vi.hoisted(() => vi.fn()); const mockEnsurePersistedExecutionWorkspaceAvailable = vi.hoisted(() => vi.fn()); const mockBuildWorkspaceRuntimeDesiredStatePatch = vi.hoisted(() => vi.fn()); +const mockListConfiguredRuntimeServiceEntries = vi.hoisted(() => vi.fn()); // The integrated control path also takes the durable runtime-control lease (PAP-17205). This // suite covers the in-flight guard and failed-start reconciliation, so the lease always grants; // `execution-workspace-runtime-lease-route.test.ts` exercises the lease itself against a real db. const mockClaimRuntimeLease = vi.hoisted(() => vi.fn(async () => null)); const mockReleaseRuntimeLease = vi.hoisted(() => vi.fn(async () => undefined)); +const mockSpawn = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", async () => ({ + ...(await vi.importActual("node:child_process")), + spawn: mockSpawn, +})); vi.mock("../telemetry.js", () => ({ getTelemetryClient: mockGetTelemetryClient })); @@ -45,14 +57,14 @@ vi.mock("../services/index.js", () => ({ claim: mockClaimRuntimeLease, release: mockReleaseRuntimeLease, }), - LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"], + LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"], })); vi.mock("../services/workspace-runtime.js", () => ({ buildWorkspaceRuntimeDesiredStatePatch: mockBuildWorkspaceRuntimeDesiredStatePatch, cleanupExecutionWorkspaceArtifacts: vi.fn(), ensurePersistedExecutionWorkspaceAvailable: mockEnsurePersistedExecutionWorkspaceAvailable, - listConfiguredRuntimeServiceEntries: vi.fn(() => []), + listConfiguredRuntimeServiceEntries: mockListConfiguredRuntimeServiceEntries, runWorkspaceJobForControl: vi.fn(), startRuntimeServicesForWorkspaceControl: mockStartRuntimeServices, stopRuntimeServicesForExecutionWorkspace: mockStopRuntimeServicesForExecutionWorkspace, @@ -65,6 +77,9 @@ vi.mock("../routes/workspace-runtime-service-authz.js", () => ({ })); const executionWorkspaceId = "33333333-3333-4333-8333-333333333333"; +const projectId = "44444444-4444-4444-8444-444444444444"; +const projectWorkspaceId = "55555555-5555-4555-8555-555555555555"; +let registeredProjectWorkspace: { id: string; cwd: string; metadata: null } | null = null; function buildExecutionWorkspace(overrides: Record = {}) { return { @@ -122,11 +137,100 @@ async function createApp() { }; next(); }); - app.use("/api", executionWorkspaceRoutes({} as any)); + const db = { + select: (selection: Record) => ({ + from: () => ({ + where: () => Promise.resolve( + Object.prototype.hasOwnProperty.call(selection, "cwd") + ? registeredProjectWorkspace ? [registeredProjectWorkspace] : [] + : [{ executionWorkspacePolicy: null }], + ), + }), + }), + }; + app.use("/api", executionWorkspaceRoutes(db as any)); app.use(errorHandler); return app; } +function createRegisteredRepairFixture( + prefix: string, + options: { targetInstanceId?: string; withCli?: boolean } = {}, +) { + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const baseCwd = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}base-`)); + const configDir = path.join(workspaceCwd, ".paperclip"); + const sourceConfigPath = path.join(baseCwd, ".paperclip", "config.json"); + const targetInstanceId = options.targetInstanceId ?? "repair-target"; + const cliRunner = path.join(baseCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs"); + const cliEntry = path.join(baseCwd, "cli", "src", "index.ts"); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.writeFileSync(sourceConfigPath, "{}\n"); + fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=repair-source\n"); + fs.writeFileSync(path.join(configDir, "config.json"), "{}\n"); + fs.writeFileSync(path.join(configDir, ".env"), `PAPERCLIP_INSTANCE_ID=${targetInstanceId}\n`); + if (options.withCli !== false) { + fs.mkdirSync(path.dirname(cliRunner), { recursive: true }); + fs.mkdirSync(path.dirname(cliEntry), { recursive: true }); + fs.writeFileSync(cliRunner, "// test runner\n"); + fs.writeFileSync(cliEntry, "// test entry\n"); + } + fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "repair-source", configPath: sourceConfigPath }, + targetInstanceId, + state: "failed", + attemptId: "previous", + })); + registeredProjectWorkspace = { id: projectWorkspaceId, cwd: baseCwd, metadata: null }; + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ + cwd: workspaceCwd, + projectId, + projectWorkspaceId, + })); + return { workspaceCwd, baseCwd, configDir, sourceConfigPath, targetInstanceId }; +} + +function removeRegisteredRepairFixture(fixture: { workspaceCwd: string; baseCwd: string }) { + fs.rmSync(fixture.workspaceCwd, { recursive: true, force: true }); + fs.rmSync(fixture.baseCwd, { recursive: true, force: true }); +} + +function mockVerifiedReseed( + fixture: ReturnType, + targetInstanceId = fixture.targetInstanceId, +) { + mockSpawn.mockImplementation(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + queueMicrotask(() => { + fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath }, + snapshotAt: "2026-08-18T00:00:00.000Z", + seedMode: "full", + migrationRevision: "0142_test.sql", + targetInstanceId, + phase: "complete", + state: "verified", + attemptId: "repair-attempt", + startedAt: "2026-08-18T00:00:00.000Z", + finishedAt: "2026-08-18T00:01:00.000Z", + diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }], + })); + child.emit("exit", 0); + }); + return child; + }); +} + /** * Route-level guarantees for PAP-17249: the competing lane in the PAP-17207 report must get a * stable 409 while a control is genuinely live, and a start that fails must leave the workspace @@ -135,6 +239,8 @@ async function createApp() { describe.sequential("execution workspace runtime control conflict and failure reconciliation", () => { beforeEach(() => { vi.clearAllMocks(); + mockSpawn.mockReset(); + registeredProjectWorkspace = null; mockAccessService.decide.mockResolvedValue({ allowed: true, action: "runtime:manage", @@ -159,13 +265,14 @@ describe.sequential("execution workspace runtime control conflict and failure re repoRef: "main", }); mockStopRuntimeServicesForExecutionWorkspace.mockResolvedValue(undefined); + mockListConfiguredRuntimeServiceEntries.mockReturnValue([{ name: "app" }]); mockWorkspaceOperationService.createRecorder.mockReturnValue({ attachExecutionWorkspaceId: vi.fn(), recordOperation: async (input: any) => { // Mirror the real recorder: a throwing `run` becomes a terminal failed operation and // the error still propagates to the caller. try { - await input.run(); + await input.run(async () => undefined); } catch (error) { (error as any).recordedOperationStatus = "failed"; throw error; @@ -247,6 +354,320 @@ describe.sequential("execution workspace runtime control conflict and failure re ); }); + it("returns 422 with a stable reason when the repair seed manifest is malformed", async () => { + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-malformed-")); + try { + const configDir = path.join(workspaceCwd, ".paperclip"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "config.json"), "{}\n"); + fs.writeFileSync(path.join(configDir, "seed-manifest.json"), "{ definitely-not-json\n"); + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ cwd: workspaceCwd })); + + const res = await request(await createApp()) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + code: "workspace_repair_precondition_failed", + reason: "seed_manifest_malformed", + repairPhase: "precondition_validation", + }); + expect(mockSpawn).not.toHaveBeenCalled(); + } finally { + fs.rmSync(workspaceCwd, { recursive: true, force: true }); + } + }); + + it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)( + "rejects a %s repair manifest source before spawn or runtime mutation", + async (variant) => { + const fixture = createRegisteredRepairFixture(`paperclip-route-repair-${variant}-`); + const attackerDir = path.join(path.dirname(fixture.workspaceCwd), `${path.basename(fixture.workspaceCwd)}-attacker`); + try { + fs.mkdirSync(attackerDir, { recursive: true }); + const attackerConfig = path.join(attackerDir, "config.json"); + fs.writeFileSync(attackerConfig, "{}\n"); + fs.writeFileSync( + path.join(attackerDir, ".env"), + `PAPERCLIP_INSTANCE_ID=${variant === "foreign_instance" ? "foreign-source" : "repair-source"}\n`, + ); + const diagnosticPath = variant === "instance_mismatch" + ? fixture.sourceConfigPath + : variant === "symlink" + ? path.join(fixture.baseCwd, "source-alias.json") + : attackerConfig; + if (variant === "symlink") fs.symlinkSync(fixture.sourceConfigPath, diagnosticPath); + fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { + instanceId: variant === "foreign_instance" || variant === "instance_mismatch" + ? "foreign-source" + : "repair-source", + configPath: diagnosticPath, + }, + targetInstanceId: fixture.targetInstanceId, + state: "failed", + attemptId: "attacker-controlled", + })); + + const res = await request(await createApp()) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + code: "workspace_repair_precondition_failed", + reason: "source_registration_invalid", + repairPhase: "precondition_validation", + }); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled(); + expect(mockWorkspaceOperationService.createRecorder).not.toHaveBeenCalled(); + expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled(); + } finally { + fs.rmSync(attackerDir, { recursive: true, force: true }); + removeRegisteredRepairFixture(fixture); + } + }, + ); + + it("rejects repair when the execution workspace has no registered base workspace", async () => { + const workspaceCwd = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-route-repair-no-source-")); + try { + const configDir = path.join(workspaceCwd, ".paperclip"); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, "config.json"), "{}\n"); + fs.writeFileSync(path.join(configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "source", configPath: path.join(workspaceCwd, "missing", "config.json") }, + state: "failed", + attemptId: "previous", + })); + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ cwd: workspaceCwd })); + + const res = await request(await createApp()) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + code: "workspace_repair_precondition_failed", + reason: "source_registration_invalid", + repairPhase: "precondition_validation", + }); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled(); + expect(mockWorkspaceOperationService.assertRuntimeControlAvailable).not.toHaveBeenCalled(); + } finally { + fs.rmSync(workspaceCwd, { recursive: true, force: true }); + } + }); + + it("rejects repair when the registered base workspace has no runnable Paperclip CLI", async () => { + const fixture = createRegisteredRepairFixture("paperclip-route-repair-no-cli-", { withCli: false }); + try { + const res = await request(await createApp()) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + code: "workspace_repair_precondition_failed", + reason: "source_registration_invalid", + repairPhase: "precondition_validation", + }); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockStopRuntimeServicesForExecutionWorkspace).not.toHaveBeenCalled(); + } finally { + removeRegisteredRepairFixture(fixture); + } + }); + + it("returns 422 with a stable reason when the verified manifest names another instance", async () => { + const fixture = createRegisteredRepairFixture("paperclip-route-repair-wrong-instance-"); + try { + mockVerifiedReseed(fixture, "another-workspace-instance"); + + const res = await request(await createApp()) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(422); + expect(res.body).toMatchObject({ + code: "workspace_repair_precondition_failed", + reason: "seed_manifest_instance_mismatch", + repairPhase: "full_reseed", + }); + expect(mockStartRuntimeServices).not.toHaveBeenCalled(); + } finally { + removeRegisteredRepairFixture(fixture); + } + }); + + it("uses the recorded instance pointer consistently for readiness, handoff, and repair", async () => { + const recordedInstanceId = "recorded-repair-instance"; + const fixture = createRegisteredRepairFixture("paperclip-route-repair-success-", { + targetInstanceId: recordedInstanceId, + }); + try { + const { deriveWorktreeInstanceId } = await import("../services/workspace-instance-cleanup.js"); + const { + resolveManagedWorkspaceIdentity, + resolveManagedWorkspaceInstanceId, + } = await import("../services/managed-workspace-identity.js"); + expect(deriveWorktreeInstanceId(fixture.workspaceCwd)).not.toBe(recordedInstanceId); + expect(resolveManagedWorkspaceInstanceId(fixture.workspaceCwd)).toBe(recordedInstanceId); + expect(resolveManagedWorkspaceIdentity({ + workspaceCwd: fixture.workspaceCwd, + executionWorkspaceId, + companyId: "company-1", + env: { PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "test-root-secret" }, + })?.instanceId).toBe(recordedInstanceId); + mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({ cwd: fixture.workspaceCwd }); + mockStartRuntimeServices.mockResolvedValue([{ + id: "service-1", + status: "running", + healthStatus: "healthy", + }]); + mockSpawn.mockImplementation((_command: string, args: string[], options: { env?: NodeJS.ProcessEnv }) => { + expect(args).toEqual(expect.arrayContaining([ + "worktree", "reseed", "--from-config", fixture.sourceConfigPath, + "--seed-mode", "full", "--yes", "--backup-target", + ])); + expect(options.env).toMatchObject({ + PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1", + PAPERCLIP_WORKSPACE_BASE_CWD: fixture.baseCwd, + PAPERCLIP_PROJECT_WORKSPACE_ID: projectWorkspaceId, + }); + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + queueMicrotask(() => { + fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath }, + snapshotAt: "2026-08-18T00:00:00.000Z", + seedMode: "full", + migrationRevision: "0142_test.sql", + targetInstanceId: recordedInstanceId, + phase: "complete", + state: "verified", + attemptId: "repair-attempt", + startedAt: "2026-08-18T00:00:00.000Z", + finishedAt: "2026-08-18T00:01:00.000Z", + diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }], + })); + child.emit("exit", 0); + }); + return child; + }); + + const app = await createApp(); + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(200); + expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ executionWorkspaceId, workspaceCwd: fixture.workspaceCwd }), + ); + expect(mockStartRuntimeServices).toHaveBeenCalledTimes(1); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ action: "execution_workspace.runtime_repair" }), + ); + } finally { + removeRegisteredRepairFixture(fixture); + } + }); + + it("completes database-only repair when no managed runtime service is configured", async () => { + const fixture = createRegisteredRepairFixture("paperclip-route-repair-db-only-"); + try { + mockExecutionWorkspaceService.getById.mockResolvedValue(buildExecutionWorkspace({ + cwd: fixture.workspaceCwd, + projectId, + projectWorkspaceId, + config: { desiredState: "stopped" }, + })); + mockListConfiguredRuntimeServiceEntries.mockReturnValue([]); + mockVerifiedReseed(fixture); + + const app = await createApp(); + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(200); + expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(1); + expect(mockStartRuntimeServices).not.toHaveBeenCalled(); + expect(mockBuildWorkspaceRuntimeDesiredStatePatch).toHaveBeenCalledWith( + expect.objectContaining({ action: "stop" }), + ); + } finally { + removeRegisteredRepairFixture(fixture); + } + }); + + it("fails repair at the exact seed phase and leaves managed services stopped", async () => { + const fixture = createRegisteredRepairFixture("paperclip-route-repair-failure-"); + try { + mockEnsurePersistedExecutionWorkspaceAvailable.mockResolvedValue({ cwd: fixture.workspaceCwd }); + mockSpawn.mockImplementation(() => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stderr: PassThrough; + kill: ReturnType; + }; + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.kill = vi.fn(); + queueMicrotask(() => { + fs.writeFileSync(path.join(fixture.configDir, "seed-manifest.json"), JSON.stringify({ + version: 2, + source: { instanceId: "repair-source", configPath: fixture.sourceConfigPath }, + targetInstanceId: fixture.targetInstanceId, + state: "failed", + phase: "restore", + attemptId: "repair-attempt", + })); + child.emit("exit", 4); + }); + return child; + }); + + const progress: Array> = []; + mockWorkspaceOperationService.createRecorder.mockReturnValue({ + attachExecutionWorkspaceId: vi.fn(), + recordOperation: async (input: any) => { + await input.run(async (entry: Record) => { + progress.push(entry); + }); + }, + }); + const app = await createApp(); + const res = await request(app) + .post(`/api/execution-workspaces/${executionWorkspaceId}/runtime-commands/repair`) + .send({}); + + expect(res.status).toBe(500); + expect(res.body).toEqual({ error: "Internal server error" }); + expect(progress).toEqual(expect.arrayContaining([ + expect.objectContaining({ metadata: { seedFailurePhase: "restore" } }), + ])); + expect(mockStartRuntimeServices).not.toHaveBeenCalled(); + expect(mockStopRuntimeServicesForExecutionWorkspace).toHaveBeenCalledTimes(2); + } finally { + removeRegisteredRepairFixture(fixture); + } + }); + it("reconciles once when the operation's own time budget fails a start that never settles", async () => { const { WorkspaceOperationTimeoutError } = await import("../services/workspace-operations.js"); // The recorder's ceiling fires outside `run`, so only the outer handler can reconcile. diff --git a/server/src/__tests__/execution-workspaces-routes.test.ts b/server/src/__tests__/execution-workspaces-routes.test.ts index 1d113f46bfe..89111b9b0aa 100644 --- a/server/src/__tests__/execution-workspaces-routes.test.ts +++ b/server/src/__tests__/execution-workspaces-routes.test.ts @@ -19,6 +19,7 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({ const mockWorkspaceOperationService = vi.hoisted(() => ({ listForExecutionWorkspace: vi.fn(), createRecorder: vi.fn(), + assertRuntimeControlAvailable: vi.fn(async () => undefined), })); const mockWorkspaceRuntimeLeaseService = vi.hoisted(() => ({ @@ -47,7 +48,7 @@ vi.mock("../services/index.js", () => ({ logActivity: mockLogActivity, workspaceOperationService: () => mockWorkspaceOperationService, workspaceRuntimeLeaseService: () => mockWorkspaceRuntimeLeaseService, - LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"], + LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"], })); vi.mock("../services/environment-runtime.js", () => ({ 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__/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__/health-workspace-readiness.test.ts b/server/src/__tests__/health-workspace-readiness.test.ts new file mode 100644 index 00000000000..d4d4d68d095 --- /dev/null +++ b/server/src/__tests__/health-workspace-readiness.test.ts @@ -0,0 +1,178 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { healthRoutes } from "../routes/health.js"; +import { + WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY, + WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY, + WORKSPACE_HANDOFF_KEY_ENV_KEY, + WORKSPACE_READINESS_TOKEN_ENV_KEY, + WORKSPACE_READINESS_TOKEN_HEADER, + WORKSPACE_READINESS_USER_EMAIL_HEADER, + WORKSPACE_READINESS_USER_ID_HEADER, +} from "../auth/workspace-login-handoff.js"; + +const tempDirs: string[] = []; +const envKeys = [ + "PAPERCLIP_CONFIG", + WORKSPACE_HANDOFF_KEY_ENV_KEY, + WORKSPACE_READINESS_TOKEN_ENV_KEY, + WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY, + WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY, +] as const; +const previousEnv = new Map(); + +function setEnv(values: Record) { + for (const key of envKeys) { + if (!previousEnv.has(key)) previousEnv.set(key, process.env[key]); + } + for (const [key, value] of Object.entries(values)) process.env[key] = value; +} + +function createSeededWorkspace() { + const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-workspace-")); + tempDirs.push(dir); + writeFileSync(path.join(dir, "config.json"), "{}\n", "utf8"); + writeFileSync( + path.join(dir, "seed-manifest.json"), + JSON.stringify({ version: 2, state: "verified", phase: "complete", seedMode: "minimal" }), + "utf8", + ); + return path.join(dir, "config.json"); +} + +/** + * Health's own queries (`instance_user_roles` / `invites` counts) and the + * readiness probe's queries both come through `select`; a count projection + * answers 1 and a row projection answers one row. + */ +function stubDb() { + return { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + select: vi.fn((projection?: Record) => { + const rows = projection && "count" in projection ? [{ count: 1 }] : [{ companyId: "company-1" }]; + const chain: Record = {}; + for (const method of ["from", "where", "innerJoin", "limit", "orderBy"]) { + chain[method] = vi.fn(() => chain); + } + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve); + return chain; + }), + } as unknown as Db; +} + +function createApp(actorType: "board" | "none") { + const app = express(); + app.use((req, _res, next) => { + (req as express.Request & { actor: { type: string } }).actor = { type: actorType }; + next(); + }); + app.use( + "/api/health", + healthRoutes(stubDb(), { + deploymentMode: "authenticated", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }), + ); + return app; +} + +afterEach(() => { + for (const [key, value] of previousEnv) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + previousEnv.clear(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("GET /api/health workspace readiness", () => { + it("exposes readiness to a board actor of a managed workspace", async () => { + setEnv({ + PAPERCLIP_CONFIG: createSeededWorkspace(), + [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key", + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1", + [WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1", + }); + const response = await request(createApp("board")).get("/api/health").expect(200); + expect(response.body.workspace).toMatchObject({ + state: "ready", + databaseReady: true, + cloneDataReady: true, + authHandoffReady: true, + seedState: "verified", + executionWorkspaceId: "ews-1", + companyId: "company-1", + }); + }); + + it("keeps public health redacted for an anonymous caller", async () => { + setEnv({ + PAPERCLIP_CONFIG: createSeededWorkspace(), + [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key", + [WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token", + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1", + [WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1", + }); + const response = await request(createApp("none")).get("/api/health").expect(200); + expect(response.body.status).toBe("ok"); + expect(response.body.workspace).toBeUndefined(); + expect(response.body.serverInfo).toBeUndefined(); + }); + + it("accepts the derived probe token on an otherwise redacted response", async () => { + setEnv({ + PAPERCLIP_CONFIG: createSeededWorkspace(), + [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key", + [WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token", + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1", + [WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1", + }); + const response = await request(createApp("none")) + .get("/api/health") + .set(WORKSPACE_READINESS_TOKEN_HEADER, "probe-token") + .set(WORKSPACE_READINESS_USER_ID_HEADER, "user-1") + .set(WORKSPACE_READINESS_USER_EMAIL_HEADER, "operator@example.com") + .expect(200); + expect(response.body.workspace).toMatchObject({ + state: "ready", + instanceId: expect.any(String), + authHandoffUserId: "user-1", + }); + // Still redacted apart from readiness: the token buys one contract, not full detail. + expect(response.body.serverInfo).toBeUndefined(); + }); + + it("rejects a wrong probe token without leaking readiness", async () => { + setEnv({ + PAPERCLIP_CONFIG: createSeededWorkspace(), + [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key", + [WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token", + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1", + [WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1", + }); + const response = await request(createApp("none")) + .get("/api/health") + .set(WORKSPACE_READINESS_TOKEN_HEADER, "probe-token-but-wrong") + .expect(200); + expect(response.body.workspace).toBeUndefined(); + }); + + it("omits readiness entirely on an instance that is not a cloned workspace", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-primary-")); + tempDirs.push(dir); + writeFileSync(path.join(dir, "config.json"), "{}\n", "utf8"); + setEnv({ PAPERCLIP_CONFIG: path.join(dir, "config.json") }); + delete process.env[WORKSPACE_HANDOFF_KEY_ENV_KEY]; + delete process.env[WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]; + const response = await request(createApp("board")).get("/api/health").expect(200); + expect(response.body.workspace).toBeUndefined(); + }); +}); 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__/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__/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/__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__/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__/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__/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/__tests__/sandbox-capability-contract.test.ts b/server/src/__tests__/sandbox-capability-contract.test.ts index 16300432a21..1c9c9336205 100644 --- a/server/src/__tests__/sandbox-capability-contract.test.ts +++ b/server/src/__tests__/sandbox-capability-contract.test.ts @@ -290,6 +290,82 @@ 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_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, @@ -298,6 +374,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/__tests__/work-product-runtime-reconciliation.test.ts b/server/src/__tests__/work-product-runtime-reconciliation.test.ts new file mode 100644 index 00000000000..345d7fcf5cd --- /dev/null +++ b/server/src/__tests__/work-product-runtime-reconciliation.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { IssueWorkProduct } from "@paperclipai/shared"; +import { reconcileRuntimeServiceWorkProducts } from "../services/work-products.js"; + +function workProduct(overrides: Partial = {}): IssueWorkProduct { + return { + id: "wp-1", + companyId: "company-1", + projectId: "project-1", + issueId: "issue-1", + executionWorkspaceId: "ews-1", + runtimeServiceId: "runtime-1", + type: "runtime_service", + provider: "paperclip", + externalId: null, + title: "Workspace preview", + url: "https://workspace.example.ts.net:42013/", + status: "open", + reviewState: "none", + isPrimary: true, + healthStatus: "healthy", + summary: null, + metadata: null, + sourceTrust: null, + createdByRunId: null, + createdAt: new Date("2026-08-19T00:00:00.000Z"), + updatedAt: new Date("2026-08-19T00:00:00.000Z"), + ...overrides, + }; +} + +describe("reconcileRuntimeServiceWorkProducts", () => { + it("adopts the live runtime URL after a port change", () => { + const [reconciled] = reconcileRuntimeServiceWorkProducts( + [workProduct()], + [{ id: "runtime-1", url: "https://workspace.example.ts.net:42055/", status: "running", healthStatus: "healthy" }], + ); + expect(reconciled!.url).toBe("https://workspace.example.ts.net:42055/"); + expect(reconciled!.healthStatus).toBe("healthy"); + }); + + it("marks a stopped or unhealthy runtime unhealthy instead of advertising it", () => { + const [stopped] = reconcileRuntimeServiceWorkProducts( + [workProduct()], + [{ id: "runtime-1", url: "https://workspace.example.ts.net:42013/", status: "stopped", healthStatus: "healthy" }], + ); + expect(stopped!.healthStatus).toBe("unhealthy"); + + const [unhealthy] = reconcileRuntimeServiceWorkProducts( + [workProduct()], + [{ id: "runtime-1", url: "https://workspace.example.ts.net:42013/", status: "running", healthStatus: "unhealthy" }], + ); + expect(unhealthy!.healthStatus).toBe("unhealthy"); + }); + + it("keeps the recorded URL when the runtime row is gone, but stops calling it healthy", () => { + const [reconciled] = reconcileRuntimeServiceWorkProducts([workProduct()], []); + expect(reconciled!.url).toBe("https://workspace.example.ts.net:42013/"); + expect(reconciled!.healthStatus).toBe("unhealthy"); + }); + + it("leaves non-runtime work products and unlinked rows untouched", () => { + const pullRequest = workProduct({ id: "wp-pr", type: "pull_request", runtimeServiceId: null }); + const unlinked = workProduct({ id: "wp-unlinked", runtimeServiceId: null }); + const reconciled = reconcileRuntimeServiceWorkProducts([pullRequest, unlinked], []); + expect(reconciled[0]).toBe(pullRequest); + expect(reconciled[1]).toBe(unlinked); + }); + + it("returns the same objects when nothing drifted", () => { + const product = workProduct(); + const reconciled = reconcileRuntimeServiceWorkProducts( + [product], + [{ id: "runtime-1", url: product.url, status: "running", healthStatus: "healthy" }], + ); + expect(reconciled[0]).toBe(product); + }); +}); diff --git a/server/src/__tests__/workspace-instance-cleanup.test.ts b/server/src/__tests__/workspace-instance-cleanup.test.ts index c46b3bc272e..353fceec724 100644 --- a/server/src/__tests__/workspace-instance-cleanup.test.ts +++ b/server/src/__tests__/workspace-instance-cleanup.test.ts @@ -114,7 +114,7 @@ describe("worktree instance cleanup", () => { await expect(fs.stat(instanceRoot)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("falls back to deterministic instance ownership when persisted root metadata is absent", async () => { + it("preserves a collision-resistant active instance when persisted ownership is absent", async () => { const worktreesDir = await makeTempRoot("paperclip-managed-worktrees-"); const workspacePath = await makeTempRoot("paperclip-cleanup-workspace-"); const instanceId = deriveWorktreeInstanceId(workspacePath); @@ -133,8 +133,9 @@ describe("worktree instance cleanup", () => { worktreesDir, }); - expect(result).toMatchObject({ status: "removed", instanceRoot }); - await expect(fs.stat(instanceRoot)).rejects.toMatchObject({ code: "ENOENT" }); + expect(result).toMatchObject({ status: "refused", instanceRoot }); + expect((result as { warning: string }).warning).toContain("no persisted instance root"); + await expect(fs.readFile(path.join(instanceRoot, "marker"), "utf8")).resolves.toBe("remove me"); }); it("refuses and logs an instance pointer outside the managed worktree root", async () => { diff --git a/server/src/__tests__/workspace-login-handoff-endpoint.test.ts b/server/src/__tests__/workspace-login-handoff-endpoint.test.ts new file mode 100644 index 00000000000..b8270f1c344 --- /dev/null +++ b/server/src/__tests__/workspace-login-handoff-endpoint.test.ts @@ -0,0 +1,251 @@ +/** + * HTTP-level coverage for the Better Auth exchange endpoint (PAP-17572). + * + * The security matrix itself lives in `workspace-login-handoff.test.ts`, which + * exercises the pure verifier and the dependency-injected exchange. This suite + * proves the wiring a browser actually meets: the plugin is registered on the + * Better Auth mount, a valid ticket answers with a redirect and a session cookie, + * a rejected ticket redirects to the labeled credential fallback without one, and + * the exchange never trusts a forwarded host header. + */ + +import express from "express"; +import request from "supertest"; +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { toNodeHandler } from "better-auth/node"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { + deriveWorkspaceHandoffKey, + issueWorkspaceHandoffTicket, + WORKSPACE_HANDOFF_TICKET_QUERY_PARAM, +} from "../auth/workspace-login-handoff.js"; +import { workspaceLoginHandoffPlugin } from "../auth/workspace-login-handoff-plugin.js"; + +const ROOT_SECRET = "endpoint-test-root-secret"; +const INSTANCE_ID = "pap-17572-endpoint-1122334455"; +const EXECUTION_WORKSPACE_ID = "ews-endpoint-1"; +const COMPANY_ID = "company-endpoint-1"; +const ORIGIN = "http://workspace.localhost:42013"; +const USER_ID = "cloned-user-1"; +const USER_EMAIL = "operator@example.com"; + +const KEY = deriveWorkspaceHandoffKey({ + rootSecret: ROOT_SECRET, + instanceId: INSTANCE_ID, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, +}); + +type MemoryStore = Record[]>; + +/** + * Membership lookup goes through the app `db`, not the Better Auth adapter, and + * asks for existence — so the stub answers with rows, not a count. + */ +function stubDb(activeMembershipCount: number) { + const rows = Array.from({ length: Math.max(0, activeMembershipCount) }, (_, index) => ({ + id: `membership-${index}`, + })); + return { + select: vi.fn(() => { + const chain: Record = {}; + for (const method of ["from", "where", "innerJoin", "limit", "orderBy"]) { + chain[method] = vi.fn(() => chain); + } + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve); + return chain; + }), + } as unknown as Db; +} + +function createApp(input: { + activeMembershipCount?: number; + expectedOrigin?: string | null; + expectedInstanceId?: string | null; + expectedExecutionWorkspaceId?: string | null; + expectedCompanyId?: string | null; + key?: string | null; +}) { + const store: MemoryStore = { user: [], session: [], account: [], verification: [] }; + store.user!.push({ + id: USER_ID, + email: USER_EMAIL, + name: "Operator", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const auth = betterAuth({ + secret: "better-auth-secret-for-endpoint-tests", + baseURL: ORIGIN, + trustedOrigins: [ORIGIN], + database: memoryAdapter(store), + emailAndPassword: { enabled: true }, + advanced: { useSecureCookies: false }, + plugins: [ + workspaceLoginHandoffPlugin({ + db: stubDb(input.activeMembershipCount ?? 1), + resolveExpectedIdentity: () => ({ + key: input.key === undefined ? KEY : input.key, + instanceId: input.expectedInstanceId === undefined ? INSTANCE_ID : input.expectedInstanceId, + executionWorkspaceId: + input.expectedExecutionWorkspaceId === undefined + ? EXECUTION_WORKSPACE_ID + : input.expectedExecutionWorkspaceId, + companyId: input.expectedCompanyId === undefined ? COMPANY_ID : input.expectedCompanyId, + origin: input.expectedOrigin === undefined ? ORIGIN : input.expectedOrigin, + }), + }), + ], + }); + + const app = express(); + app.all("/api/auth/*splat", (req, res, next) => { + void Promise.resolve(toNodeHandler(auth)(req, res)).catch(next); + }); + return { app, store }; +} + +function mintTicket(overrides: Partial[0]> = {}) { + return issueWorkspaceHandoffTicket({ + key: KEY, + userId: USER_ID, + email: USER_EMAIL, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + instanceId: INSTANCE_ID, + origin: ORIGIN, + issuerInstanceId: "primary", + ...overrides, + }).ticket; +} + +function exchange(app: express.Express, ticket: string | null) { + const path = `/api/auth/workspace-handoff/exchange${ + ticket === null ? "" : `?${WORKSPACE_HANDOFF_TICKET_QUERY_PARAM}=${encodeURIComponent(ticket)}` + }`; + return request(app).get(path); +} + +function sessionCookies(response: request.Response): string[] { + const raw = response.headers["set-cookie"]; + const cookies = Array.isArray(raw) ? raw : raw ? [raw] : []; + return cookies.filter((cookie) => cookie.includes("session_token")); +} + +beforeEach(() => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("GET /api/auth/workspace-handoff/exchange", () => { + it("redirects to the requested board path and sets exactly one session cookie", async () => { + const { app, store } = createApp({}); + const response = await exchange(app, mintTicket({ next: "/PAP/issues/PAP-17572" })); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe(`${ORIGIN}/PAP/issues/PAP-17572`); + // The ticket must not survive into the landing URL, the cache, or the referrer. + expect(response.headers.location).not.toContain(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM); + expect(response.headers["cache-control"]).toContain("no-store"); + expect(response.headers["referrer-policy"]).toBe("no-referrer"); + expect(sessionCookies(response)).toHaveLength(1); + expect(store.session).toHaveLength(1); + expect(store.session![0]!.userId).toBe(USER_ID); + }); + + it("does not create a second session for a replayed ticket", async () => { + const { app, store } = createApp({}); + const ticket = mintTicket(); + expect((await exchange(app, ticket)).status).toBe(302); + + const replay = await exchange(app, ticket); + expect(replay.status).toBe(302); + expect(replay.headers.location).toContain("workspaceHandoffError=replayed"); + expect(sessionCookies(replay)).toHaveLength(0); + expect(store.session).toHaveLength(1); + }); + + it("sends a rejected ticket to the labeled credential fallback with no session", async () => { + const { app, store } = createApp({}); + const expired = mintTicket({ now: new Date(Date.now() - 10 * 60_000), ttlSeconds: 30 }); + const response = await exchange(app, expired); + + expect(response.status).toBe(302); + const location = new URL(response.headers.location as string); + expect(location.pathname).toBe("/auth"); + expect(location.searchParams.get("workspaceHandoffError")).toBe("expired"); + expect(sessionCookies(response)).toHaveLength(0); + expect(store.session).toHaveLength(0); + }); + + it("rejects a ticket for a cloned user with no active membership", async () => { + const { app, store } = createApp({ activeMembershipCount: 0 }); + const response = await exchange(app, mintTicket()); + expect(response.headers.location).toContain("workspaceHandoffError=missing_membership"); + expect(store.session).toHaveLength(0); + }); + + it("rejects a ticket naming a user that did not survive the clone", async () => { + const { app, store } = createApp({}); + const response = await exchange(app, mintTicket({ userId: "user-that-was-not-cloned" })); + expect(response.headers.location).toContain("workspaceHandoffError=unknown_user"); + expect(store.session).toHaveLength(0); + }); + + it("ignores forwarded host and proto headers when checking the audience", async () => { + const { app, store } = createApp({}); + // A ticket minted for the attacker's host must fail even when the request + // arrives claiming to be that host. + const spoofedTicket = mintTicket({ origin: "https://attacker.example.com" }); + const response = await exchange(app, spoofedTicket) + .set("host", "attacker.example.com") + .set("x-forwarded-host", "attacker.example.com") + .set("x-forwarded-proto", "https") + .set("x-forwarded-for", "203.0.113.7"); + + expect(response.headers.location).toContain("workspaceHandoffError=origin_mismatch"); + expect(sessionCookies(response)).toHaveLength(0); + expect(store.session).toHaveLength(0); + }); + + it("rejects a ticket bound to another workspace, instance, or company on this host", async () => { + const { app } = createApp({}); + const otherWorkspace = await exchange(app, mintTicket({ executionWorkspaceId: "ews-sibling" })); + expect(otherWorkspace.headers.location).toContain("workspaceHandoffError=workspace_mismatch"); + + const otherInstance = await exchange(app, mintTicket({ instanceId: "another-instance" })); + expect(otherInstance.headers.location).toContain("workspaceHandoffError=instance_mismatch"); + + const otherCompany = await exchange(app, mintTicket({ companyId: "company-sibling" })); + expect(otherCompany.headers.location).toContain("workspaceHandoffError=company_mismatch"); + }); + + it("rejects a missing or garbage ticket", async () => { + const { app } = createApp({}); + expect((await exchange(app, null)).headers.location).toContain("workspaceHandoffError=malformed"); + expect((await exchange(app, "wh1.aaaa.bbbb")).headers.location).toContain( + "workspaceHandoffError=bad_signature", + ); + }); + + it("answers 503 rather than a redirect when the guest has no verification key", async () => { + const { app } = createApp({ key: null }); + const response = await exchange(app, mintTicket()); + expect(response.status).toBe(503); + expect(sessionCookies(response)).toHaveLength(0); + }); + + it("fails closed when the guest cannot resolve its own origin", async () => { + const { app, store } = createApp({ expectedOrigin: null }); + const response = await exchange(app, mintTicket()); + expect(response.status).toBe(302); + expect(response.headers.location).toContain("workspaceHandoffError=origin_mismatch"); + expect(store.session).toHaveLength(0); + }); +}); diff --git a/server/src/__tests__/workspace-login-handoff-issuer.test.ts b/server/src/__tests__/workspace-login-handoff-issuer.test.ts new file mode 100644 index 00000000000..b98ce3cf404 --- /dev/null +++ b/server/src/__tests__/workspace-login-handoff-issuer.test.ts @@ -0,0 +1,302 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import { + issueWorkspaceLoginHandoff, + resolveWorkspaceHandoffBoardIdentity, + workspaceLoginHandoffFailureStatus, +} from "../services/workspace-login-handoff-issuer.js"; +import { verifyWorkspaceHandoffTicket } from "../auth/workspace-login-handoff.js"; +import { resolveManagedWorkspaceIdentity } from "../services/managed-workspace-identity.js"; +import { WORKSPACE_HANDOFF_TICKET_QUERY_PARAM } from "../auth/workspace-login-handoff.js"; + +const INSTANCE_ID = "pap-17572-live-workspace-aa11bb22cc33"; +const EXECUTION_WORKSPACE_ID = "ews-live-1"; +const COMPANY_ID = "company-1"; +const RUNTIME_URL = "https://workspace.example.ts.net:42013/"; + +const tempDirs: string[] = []; +let previousSecret: string | undefined; + +function createWorkspaceCwd(instanceId: string | null) { + const cwd = mkdtempSync(path.join(os.tmpdir(), "paperclip-handoff-issuer-")); + tempDirs.push(cwd); + mkdirSync(path.join(cwd, ".paperclip"), { recursive: true }); + if (instanceId) { + writeFileSync( + path.join(cwd, ".paperclip", ".env"), + `PAPERCLIP_HOME=/srv/home\nPAPERCLIP_INSTANCE_ID=${instanceId}\n`, + "utf8", + ); + } + return cwd; +} + +/** + * Minimal drizzle-shaped stub. `select({ id, url, updatedAt })` is the live + * runtime lookup; anything else is the instance-admin lookup. + */ +function stubDb(input: { + runtimeRows?: Array<{ id: string; url: string | null; updatedAt: Date }>; + adminRows?: Array<{ id: string; email: string | null; createdAt: Date }>; +}) { + return { + select: vi.fn((projection: Record) => { + const rows = "url" in projection ? input.runtimeRows ?? [] : input.adminRows ?? []; + const chain: Record = {}; + for (const method of ["from", "where", "innerJoin", "orderBy", "limit"]) { + chain[method] = vi.fn(() => chain); + } + chain.then = (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve); + return chain; + }), + } as unknown as Db; +} + +function readyProbe() { + return vi.fn(async () => ({ + ok: true as const, + readiness: { + state: "ready" as const, + databaseReady: true, + cloneDataReady: true, + authHandoffReady: true, + authHandoffUserId: "user-1", + seedState: "verified" as const, + seedPhase: "complete", + seedMode: "minimal" as const, + instanceId: INSTANCE_ID, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + failurePhase: null, + }, + })); +} + +beforeEach(() => { + previousSecret = process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET; + process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET = "issuer-test-root-secret"; +}); + +afterEach(() => { + if (previousSecret === undefined) delete process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET; + else process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET = previousSecret; + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe("issueWorkspaceLoginHandoff", () => { + it("mints a ticket the target workspace accepts, bound to the live runtime origin", async () => { + const cwd = createWorkspaceCwd(INSTANCE_ID); + const probe = readyProbe(); + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd }, + actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" }, + next: "/PAP/issues/PAP-17572", + probe, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const url = new URL(result.issuance.url); + expect(url.origin).toBe("https://workspace.example.ts.net:42013"); + expect(url.pathname).toBe("/api/auth/workspace-handoff/exchange"); + + // The guest independently derives the same key, so the round trip proves the + // control plane and the workspace agree without sharing a stored token. + const identity = resolveManagedWorkspaceIdentity({ + workspaceCwd: cwd, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + }); + const verification = verifyWorkspaceHandoffTicket({ + ticket: url.searchParams.get(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM), + key: identity!.handoffKey, + expected: { + instanceId: INSTANCE_ID, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + origin: "https://workspace.example.ts.net:42013", + }, + }); + expect(verification.ok).toBe(true); + expect(verification.ok && verification.payload).toMatchObject({ + sub: "user-1", + email: "operator@example.com", + next: "/PAP/issues/PAP-17572", + }); + // Readiness is probed against the origin the user will be sent to, not a + // separately configured address. + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0]![0]!.healthUrl).toBe("https://workspace.example.ts.net:42013/api/health"); + expect(probe.mock.calls[0]![0]!.handoffSubject).toEqual({ + userId: "user-1", + email: "operator@example.com", + }); + }); + + it("refuses to mint a ticket for a workspace that is not ready", async () => { + const cwd = createWorkspaceCwd(INSTANCE_ID); + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd }, + actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" }, + probe: vi.fn(async () => ({ + ok: false as const, + reason: "not_ready" as const, + readiness: null, + detail: "clone_data_missing", + })), + }); + expect(result).toMatchObject({ + ok: false, + failure: { reason: "workspace_not_ready", detail: "clone_data_missing" }, + }); + }); + + it("refuses when no healthy runtime row publishes a URL", async () => { + const cwd = createWorkspaceCwd(INSTANCE_ID); + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ runtimeRows: [] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd }, + actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" }, + probe: readyProbe(), + }); + expect(result).toMatchObject({ ok: false, failure: { reason: "runtime_not_running" } }); + }); + + it("refuses a runtime row whose URL is not an absolute http(s) origin", async () => { + const cwd = createWorkspaceCwd(INSTANCE_ID); + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ runtimeRows: [{ id: "runtime-1", url: "not-a-url", updatedAt: new Date() }] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd }, + actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" }, + probe: readyProbe(), + }); + expect(result).toMatchObject({ ok: false, failure: { reason: "runtime_url_unusable" } }); + }); + + it("fails closed when the worktree instance identity cannot be resolved", async () => { + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd: null }, + actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" }, + probe: readyProbe(), + }); + expect(result).toMatchObject({ ok: false, failure: { reason: "handoff_not_configured" } }); + }); + + it("falls back to credentials when no signing secret is configured at all", async () => { + delete process.env.PAPERCLIP_WORKSPACE_HANDOFF_SECRET; + const previousAuthSecret = process.env.BETTER_AUTH_SECRET; + const previousJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.BETTER_AUTH_SECRET; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + try { + const cwd = createWorkspaceCwd(INSTANCE_ID); + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd }, + actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" }, + probe: readyProbe(), + }); + expect(result).toMatchObject({ ok: false, failure: { reason: "handoff_not_configured" } }); + } finally { + if (previousAuthSecret !== undefined) process.env.BETTER_AUTH_SECRET = previousAuthSecret; + if (previousJwtSecret !== undefined) process.env.PAPERCLIP_AGENT_JWT_SECRET = previousJwtSecret; + } + }); + + it("refuses an actor with no resolvable board identity", async () => { + const cwd = createWorkspaceCwd(INSTANCE_ID); + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ adminRows: [] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd }, + actor: { userId: null, userEmail: null, source: "session" }, + probe: readyProbe(), + }); + expect(result).toMatchObject({ ok: false, failure: { reason: "no_board_identity" } }); + }); + + it("normalizes a landing target that tries to leave the workspace origin", async () => { + const cwd = createWorkspaceCwd(INSTANCE_ID); + const result = await issueWorkspaceLoginHandoff({ + db: stubDb({ runtimeRows: [{ id: "runtime-1", url: RUNTIME_URL, updatedAt: new Date() }] }), + companyId: COMPANY_ID, + executionWorkspace: { id: EXECUTION_WORKSPACE_ID, cwd }, + actor: { userId: "user-1", userEmail: "operator@example.com", source: "session" }, + next: "https://attacker.example.com/steal", + probe: readyProbe(), + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + const identity = resolveManagedWorkspaceIdentity({ + workspaceCwd: cwd, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + }); + const verification = verifyWorkspaceHandoffTicket({ + ticket: new URL(result.issuance.url).searchParams.get(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM), + key: identity!.handoffKey, + expected: { + instanceId: INSTANCE_ID, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + origin: "https://workspace.example.ts.net:42013", + }, + }); + expect(verification.ok && verification.payload.next).toBe("/"); + }); +}); + +describe("resolveWorkspaceHandoffBoardIdentity", () => { + it("uses the caller's own session identity", async () => { + await expect( + resolveWorkspaceHandoffBoardIdentity(stubDb({}), { + userId: "user-9", + userEmail: "nine@example.com", + source: "session", + }), + ).resolves.toEqual({ userId: "user-9", email: "nine@example.com" }); + }); + + it("stands in the instance admin for the implicit local-trusted actor", async () => { + await expect( + resolveWorkspaceHandoffBoardIdentity( + stubDb({ adminRows: [{ id: "admin-1", email: "admin@example.com", createdAt: new Date(0) }] }), + { userId: "local-board", userEmail: null, source: "local_implicit" }, + ), + ).resolves.toEqual({ userId: "admin-1", email: "admin@example.com" }); + }); + + it("returns null when a local-trusted instance has no instance admin", async () => { + await expect( + resolveWorkspaceHandoffBoardIdentity(stubDb({ adminRows: [] }), { + userId: "local-board", + userEmail: null, + source: "local_implicit", + }), + ).resolves.toBeNull(); + }); +}); + +describe("workspaceLoginHandoffFailureStatus", () => { + it("separates authorization, unsupported, and conflict outcomes", () => { + expect(workspaceLoginHandoffFailureStatus({ reason: "no_board_identity" })).toBe(403); + expect(workspaceLoginHandoffFailureStatus({ reason: "handoff_not_configured" })).toBe(501); + expect(workspaceLoginHandoffFailureStatus({ reason: "runtime_not_running" })).toBe(409); + expect( + workspaceLoginHandoffFailureStatus({ reason: "workspace_not_ready", detail: null, readiness: null }), + ).toBe(409); + }); +}); diff --git a/server/src/__tests__/workspace-login-handoff.test.ts b/server/src/__tests__/workspace-login-handoff.test.ts new file mode 100644 index 00000000000..212a8be39e0 --- /dev/null +++ b/server/src/__tests__/workspace-login-handoff.test.ts @@ -0,0 +1,397 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildWorkspaceHandoffExchangeUrl, + deriveWorkspaceHandoffKey, + deriveWorkspaceReadinessToken, + issueWorkspaceHandoffTicket, + normalizeWorkspaceHandoffOrigin, + redactWorkspaceHandoffTicket, + resolveWorkspaceHandoffRootSecret, + sanitizeWorkspaceHandoffRedirectPath, + verifyWorkspaceHandoffTicket, + WORKSPACE_HANDOFF_TICKET_VERSION, +} from "../auth/workspace-login-handoff.js"; +import { + exchangeWorkspaceHandoffTicket, + isWorkspaceHandoffRepairReason, + workspaceHandoffFailureStatus, + type WorkspaceHandoffExchangeDeps, +} from "../auth/workspace-login-handoff-exchange.js"; + +const ROOT_SECRET = "root-secret-for-tests"; +const INSTANCE_ID = "pap-17572-workspace-abc123def456"; +const EXECUTION_WORKSPACE_ID = "ews-1111-2222"; +const COMPANY_ID = "company-1111"; +const ORIGIN = "https://workspace.example.ts.net:42013"; +const USER_ID = "user-1"; +const USER_EMAIL = "Operator@Example.com"; + +const KEY = deriveWorkspaceHandoffKey({ + rootSecret: ROOT_SECRET, + instanceId: INSTANCE_ID, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, +}); + +const EXPECTED = { + instanceId: INSTANCE_ID, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + origin: ORIGIN, +}; + +function mintTicket(overrides: Partial[0]> = {}) { + return issueWorkspaceHandoffTicket({ + key: KEY, + userId: USER_ID, + email: USER_EMAIL, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + instanceId: INSTANCE_ID, + origin: ORIGIN, + issuerInstanceId: "primary", + ...overrides, + }); +} + +function exchangeDeps(overrides: Partial = {}) { + const reserved = new Set(); + return { + findClonedIdentity: vi.fn(async ({ userId }: { userId: string; companyId: string }) => + userId === USER_ID + ? { userId, email: USER_EMAIL, hasActiveMembership: true } + : null, + ), + reserveNonce: vi.fn(async ({ nonce }: { nonce: string }) => { + if (reserved.has(nonce)) return false; + reserved.add(nonce); + return true; + }), + createSession: vi.fn(async () => undefined), + ...overrides, + } satisfies WorkspaceHandoffExchangeDeps; +} + +describe("workspace handoff key derivation", () => { + it("derives a distinct key per instance and per workspace", () => { + const otherWorkspace = deriveWorkspaceHandoffKey({ + rootSecret: ROOT_SECRET, + instanceId: INSTANCE_ID, + executionWorkspaceId: "ews-other", + }); + const otherInstance = deriveWorkspaceHandoffKey({ + rootSecret: ROOT_SECRET, + instanceId: "other-instance", + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + }); + expect(KEY).not.toBe(otherWorkspace); + expect(KEY).not.toBe(otherInstance); + expect(KEY).not.toContain(ROOT_SECRET); + }); + + it("keeps the readiness probe token separate from the signing key", () => { + const token = deriveWorkspaceReadinessToken({ + rootSecret: ROOT_SECRET, + instanceId: INSTANCE_ID, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + }); + expect(token).not.toBe(KEY); + }); + + it("prefers a dedicated secret and otherwise derives one that is not the auth secret", () => { + expect( + resolveWorkspaceHandoffRootSecret({ PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "dedicated" }), + ).toEqual({ secret: "dedicated", source: "dedicated" }); + + const derived = resolveWorkspaceHandoffRootSecret({ BETTER_AUTH_SECRET: "auth-secret" }); + expect(derived?.source).toBe("derived"); + expect(derived?.secret).not.toBe("auth-secret"); + + expect(resolveWorkspaceHandoffRootSecret({})).toBeNull(); + }); +}); + +describe("verifyWorkspaceHandoffTicket", () => { + it("accepts a ticket bound to this workspace", () => { + const { ticket, payload } = mintTicket({ next: "/PAP/issues/PAP-1" }); + const result = verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED }); + expect(result).toEqual({ ok: true, payload }); + }); + + it("rejects a ticket signed with another workspace's key", () => { + const foreignKey = deriveWorkspaceHandoffKey({ + rootSecret: ROOT_SECRET, + instanceId: INSTANCE_ID, + executionWorkspaceId: "ews-other", + }); + const { ticket } = mintTicket({ key: foreignKey }); + expect(verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED })).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it("rejects a ticket minted for another origin, workspace, or instance", () => { + const wrongOrigin = mintTicket({ origin: "https://other.example.ts.net:42099" }); + expect(verifyWorkspaceHandoffTicket({ ticket: wrongOrigin.ticket, key: KEY, expected: EXPECTED }).ok).toBe(false); + expect( + verifyWorkspaceHandoffTicket({ ticket: wrongOrigin.ticket, key: KEY, expected: EXPECTED }), + ).toEqual({ ok: false, reason: "origin_mismatch" }); + + // A ticket for a sibling workspace on the same host cannot be re-signed with + // this workspace's key, so bind-mismatch is proven with a matching key. + const siblingWorkspaceKey = KEY; + const wrongWorkspace = issueWorkspaceHandoffTicket({ + key: siblingWorkspaceKey, + userId: USER_ID, + email: USER_EMAIL, + executionWorkspaceId: "ews-sibling", + companyId: COMPANY_ID, + instanceId: INSTANCE_ID, + origin: ORIGIN, + issuerInstanceId: "primary", + }); + expect(verifyWorkspaceHandoffTicket({ ticket: wrongWorkspace.ticket, key: KEY, expected: EXPECTED })).toEqual({ + ok: false, + reason: "workspace_mismatch", + }); + + const wrongInstance = issueWorkspaceHandoffTicket({ + key: KEY, + userId: USER_ID, + email: USER_EMAIL, + executionWorkspaceId: EXECUTION_WORKSPACE_ID, + companyId: COMPANY_ID, + instanceId: "some-other-instance", + origin: ORIGIN, + issuerInstanceId: "primary", + }); + expect(verifyWorkspaceHandoffTicket({ ticket: wrongInstance.ticket, key: KEY, expected: EXPECTED })).toEqual({ + ok: false, + reason: "instance_mismatch", + }); + }); + + it("rejects a ticket minted for another company on the same workspace", () => { + // The signing key is per workspace, so a same-host sibling company shares it — + // which is exactly why the company has to be bound and compared. + const { ticket } = mintTicket({ companyId: "company-other" }); + expect(verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED })).toEqual({ + ok: false, + reason: "company_mismatch", + }); + }); + + it("rejects an expired ticket and one issued in the future", () => { + const now = new Date("2026-08-19T00:00:00.000Z"); + const { ticket } = mintTicket({ now, ttlSeconds: 30 }); + expect( + verifyWorkspaceHandoffTicket({ + ticket, + key: KEY, + expected: EXPECTED, + now: new Date(now.getTime() + 121_000), + }), + ).toEqual({ ok: false, reason: "expired" }); + + expect( + verifyWorkspaceHandoffTicket({ + ticket, + key: KEY, + expected: EXPECTED, + now: new Date(now.getTime() - 120_000), + }), + ).toEqual({ ok: false, reason: "not_yet_valid" }); + }); + + it("fails closed when local identity cannot be resolved", () => { + const { ticket } = mintTicket(); + expect( + verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, origin: null } }), + ).toEqual({ ok: false, reason: "origin_mismatch" }); + expect( + verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, instanceId: null } }), + ).toEqual({ ok: false, reason: "instance_mismatch" }); + expect( + verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, executionWorkspaceId: null } }), + ).toEqual({ ok: false, reason: "workspace_mismatch" }); + expect( + verifyWorkspaceHandoffTicket({ ticket, key: KEY, expected: { ...EXPECTED, companyId: null } }), + ).toEqual({ ok: false, reason: "company_mismatch" }); + expect(verifyWorkspaceHandoffTicket({ ticket, key: "", expected: EXPECTED })).toEqual({ + ok: false, + reason: "not_configured", + }); + }); + + it("rejects malformed, truncated, and re-versioned tickets", () => { + const { ticket } = mintTicket(); + const [version, payload, signature] = ticket.split("."); + expect(verifyWorkspaceHandoffTicket({ ticket: "", key: KEY, expected: EXPECTED }).ok).toBe(false); + expect(verifyWorkspaceHandoffTicket({ ticket: `${version}.${payload}`, key: KEY, expected: EXPECTED })).toEqual({ + ok: false, + reason: "malformed", + }); + expect( + verifyWorkspaceHandoffTicket({ ticket: `wh0.${payload}.${signature}`, key: KEY, expected: EXPECTED }), + ).toEqual({ ok: false, reason: "unsupported_version" }); + expect( + verifyWorkspaceHandoffTicket({ ticket: `${version}.${payload}.not+base64url`, key: KEY, expected: EXPECTED }), + ).toEqual({ ok: false, reason: "malformed" }); + // Payload tampering has to fail on the signature, never on JSON parsing. + const tampered = Buffer.from( + JSON.stringify({ ...JSON.parse(Buffer.from(payload!, "base64url").toString()), sub: "attacker" }), + ).toString("base64url"); + expect( + verifyWorkspaceHandoffTicket({ ticket: `${version}.${tampered}.${signature}`, key: KEY, expected: EXPECTED }), + ).toEqual({ ok: false, reason: "bad_signature" }); + }); + + it("uses the declared envelope version", () => { + expect(mintTicket().ticket.startsWith(`${WORKSPACE_HANDOFF_TICKET_VERSION}.`)).toBe(true); + }); +}); + +describe("exchangeWorkspaceHandoffTicket", () => { + it("creates exactly one session for a valid ticket", async () => { + const { ticket } = mintTicket({ next: "/PAP/issues/PAP-2" }); + const deps = exchangeDeps(); + const result = await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps }); + expect(result.ok).toBe(true); + expect(result.ok && result.redirectTo).toBe("/PAP/issues/PAP-2"); + expect(deps.createSession).toHaveBeenCalledTimes(1); + expect(deps.createSession).toHaveBeenCalledWith(USER_ID); + }); + + it("rejects a replayed ticket without creating a second session", async () => { + const { ticket } = mintTicket(); + const deps = exchangeDeps(); + expect((await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps })).ok).toBe(true); + const replay = await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps }); + expect(replay).toEqual({ ok: false, reason: "replayed", payload: expect.objectContaining({ sub: USER_ID }) }); + expect(deps.createSession).toHaveBeenCalledTimes(1); + }); + + it("asks for membership in the ticket's company, not any membership", async () => { + const { ticket } = mintTicket(); + const deps = exchangeDeps(); + expect((await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps })).ok).toBe(true); + expect(deps.findClonedIdentity).toHaveBeenCalledWith({ userId: USER_ID, companyId: COMPANY_ID }); + }); + + it("reports a missing cloned user and a missing membership distinctly", async () => { + const { ticket } = mintTicket(); + const missingUser = exchangeDeps({ findClonedIdentity: vi.fn(async () => null) }); + expect( + (await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps: missingUser })), + ).toMatchObject({ ok: false, reason: "unknown_user" }); + expect(missingUser.createSession).not.toHaveBeenCalled(); + + const noMembership = exchangeDeps({ + findClonedIdentity: vi.fn(async () => ({ userId: USER_ID, email: USER_EMAIL, hasActiveMembership: false })), + }); + expect( + (await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps: noMembership })), + ).toMatchObject({ ok: false, reason: "missing_membership" }); + expect(noMembership.reserveNonce).not.toHaveBeenCalled(); + expect(noMembership.createSession).not.toHaveBeenCalled(); + }); + + it("rejects a clone whose user id was reused for a different email", async () => { + const { ticket } = mintTicket(); + const deps = exchangeDeps({ + findClonedIdentity: vi.fn(async () => ({ + userId: USER_ID, + email: "someone-else@example.com", + hasActiveMembership: true, + })), + }); + expect( + await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps }), + ).toMatchObject({ ok: false, reason: "user_mismatch" }); + expect(deps.createSession).not.toHaveBeenCalled(); + }); + + it("matches the cloned email case-insensitively", async () => { + const { ticket } = mintTicket(); + const deps = exchangeDeps({ + findClonedIdentity: vi.fn(async () => ({ + userId: USER_ID, + email: USER_EMAIL.toUpperCase(), + hasActiveMembership: true, + })), + }); + expect((await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps })).ok).toBe(true); + }); + + it("burns the nonce when session creation fails so a captured ticket cannot be retried", async () => { + const { ticket } = mintTicket(); + const deps = exchangeDeps({ + createSession: vi.fn(async () => { + throw new Error("session store unavailable"); + }), + }); + expect( + await exchangeWorkspaceHandoffTicket({ ticket, key: KEY, expected: EXPECTED, deps }), + ).toMatchObject({ ok: false, reason: "session_failed" }); + expect(deps.reserveNonce).toHaveBeenCalledTimes(1); + }); + + it("never consults request headers: identity comes only from `expected`", async () => { + // A ticket minted for the spoofed host fails against the configured origin, + // which is the whole forwarded-header defense. + const spoofed = mintTicket({ origin: "https://attacker.example.com" }); + expect( + await exchangeWorkspaceHandoffTicket({ + ticket: spoofed.ticket, + key: KEY, + expected: EXPECTED, + deps: exchangeDeps(), + }), + ).toEqual({ ok: false, reason: "origin_mismatch", payload: null }); + }); + + it("maps failures onto stable statuses that do not leak which check failed", () => { + expect(workspaceHandoffFailureStatus("not_configured")).toBe(503); + expect(workspaceHandoffFailureStatus("session_failed")).toBe(500); + for (const reason of ["expired", "replayed", "origin_mismatch", "workspace_mismatch", "company_mismatch", "unknown_user"] as const) { + expect(workspaceHandoffFailureStatus(reason)).toBe(401); + } + expect(isWorkspaceHandoffRepairReason("missing_membership")).toBe(true); + expect(isWorkspaceHandoffRepairReason("expired")).toBe(false); + }); +}); + +describe("handoff URL handling", () => { + it("reduces a landing target to a same-origin path", () => { + expect(sanitizeWorkspaceHandoffRedirectPath("/PAP/issues/PAP-1")).toBe("/PAP/issues/PAP-1"); + expect(sanitizeWorkspaceHandoffRedirectPath("https://attacker.example.com/")).toBe("/"); + expect(sanitizeWorkspaceHandoffRedirectPath("//attacker.example.com")).toBe("/"); + expect(sanitizeWorkspaceHandoffRedirectPath("/\\attacker.example.com")).toBe("/"); + expect(sanitizeWorkspaceHandoffRedirectPath("/ok\nSet-Cookie: x=1")).toBe("/"); + expect(sanitizeWorkspaceHandoffRedirectPath(undefined)).toBe("/"); + }); + + it("normalizes origins and refuses non-http schemes", () => { + expect(normalizeWorkspaceHandoffOrigin("https://host:443/path")).toBe("https://host"); + expect(normalizeWorkspaceHandoffOrigin("http://host:42013/x")).toBe("http://host:42013"); + expect(normalizeWorkspaceHandoffOrigin("file:///etc/passwd")).toBeNull(); + expect(normalizeWorkspaceHandoffOrigin("not a url")).toBeNull(); + }); + + it("builds an exchange URL on the bound origin", () => { + const { ticket } = mintTicket(); + const url = new URL(buildWorkspaceHandoffExchangeUrl({ origin: ORIGIN, ticket })); + expect(url.origin).toBe(ORIGIN); + expect(url.pathname).toBe("/api/auth/workspace-handoff/exchange"); + expect(url.searchParams.get("ticket")).toBe(ticket); + }); + + it("redacts the ticket from anything log-shaped", () => { + const { ticket } = mintTicket(); + const line = `GET /api/auth/workspace-handoff/exchange?ticket=${ticket}&next=%2F 302`; + const redacted = redactWorkspaceHandoffTicket(line); + expect(redacted).not.toContain(ticket); + expect(redacted).toContain("ticket=[redacted]"); + expect(redacted).toContain("next=%2F"); + }); +}); diff --git a/server/src/__tests__/workspace-operations-reconciliation.test.ts b/server/src/__tests__/workspace-operations-reconciliation.test.ts index 9f2808a9a1c..9133fbae0e6 100644 --- a/server/src/__tests__/workspace-operations-reconciliation.test.ts +++ b/server/src/__tests__/workspace-operations-reconciliation.test.ts @@ -44,14 +44,14 @@ describe("workspace runtime control serialization", () => { await expect(runExclusiveWorkspaceRuntimeControl({ executionWorkspaceId: "workspace-1", - action: "restart", - run: async () => "restarted", + action: "repair", + run: async () => "repaired", })).rejects.toMatchObject({ status: 409, details: { code: "workspace_runtime_control_in_progress", activeAction: "start", - requestedAction: "restart", + requestedAction: "repair", }, }); @@ -204,4 +204,71 @@ describeEmbeddedPostgres("workspace operation reconciliation", () => { .then((rows) => rows[0]); expect(unrelated?.status).toBe("running"); }); + + it("persists bounded repair phase progress on the operation row and log", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const executionWorkspaceId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Workspace repair diagnostics", + issuePrefix: `R${companyId.replace(/-/g, "").slice(0, 7).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Repair diagnostics", + status: "in_progress", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Repair diagnostics workspace", + status: "active", + cwd: "/tmp/repair-diagnostics-workspace", + }); + + const operation = await workspaceOperationService(db) + .createRecorder({ companyId, executionWorkspaceId }) + .recordOperation({ + phase: "workspace_repair", + command: "workspace command repair", + cwd: "/tmp/repair-diagnostics-workspace", + metadata: { action: "repair" }, + run: async (reportProgress) => { + await reportProgress({ + metadata: { + repairPhase: "target_backup", + repairDiagnostics: [{ + phase: "target_backup", + status: "succeeded", + at: "2026-08-18T00:00:00.000Z", + }], + databaseOnly: true, + worktreePreserved: true, + }, + system: "Workspace repair target_backup: succeeded.\n", + }); + return { status: "succeeded", metadata: { backupRetained: true } }; + }, + }); + + expect(operation).toMatchObject({ + status: "succeeded", + metadata: { + action: "repair", + repairPhase: "target_backup", + databaseOnly: true, + worktreePreserved: true, + backupRetained: true, + }, + }); + expect((await workspaceOperationService(db).readLog(operation.id)).content).toContain( + "Workspace repair target_backup: succeeded.", + ); + }); }); diff --git a/server/src/__tests__/workspace-readiness.test.ts b/server/src/__tests__/workspace-readiness.test.ts new file mode 100644 index 00000000000..262a7c8317f --- /dev/null +++ b/server/src/__tests__/workspace-readiness.test.ts @@ -0,0 +1,687 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Db } from "@paperclipai/db"; +import type { WorkspaceReadinessProbeResult } from "@paperclipai/shared"; +import { + isManagedWorkspaceInstance, + resetManagedWorkspaceInstanceCacheForTests, + resolveWorkspaceReadiness, + resolveWorkspaceReadinessState, + resolveWorkspaceSeedMarkerDir, +} from "../services/workspace-readiness.js"; +import { + buildManagedWorkspaceGuestEnv, + listManagedWorkspaceHandoffSubjects, + probeManagedWorkspaceHandoffSubjects, + probeManagedWorkspaceReadiness, + resolveManagedWorkspaceIdentity, + resolveWorkspaceReadinessGateMode, + shouldBlockPublicationOnReadiness, + waitForManagedWorkspaceReadiness, + type ManagedWorkspaceIdentity, +} from "../services/managed-workspace-identity.js"; +import { + WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY, + WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY, + WORKSPACE_HANDOFF_KEY_ENV_KEY, + WORKSPACE_READINESS_TOKEN_ENV_KEY, + WORKSPACE_READINESS_TOKEN_HEADER, + WORKSPACE_READINESS_USER_EMAIL_HEADER, + WORKSPACE_READINESS_USER_ID_HEADER, +} from "../auth/workspace-login-handoff.js"; + +const tempDirs: string[] = []; + +function createMarkerDir(files: Record = {}) { + const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-workspace-readiness-")); + tempDirs.push(dir); + const configPath = path.join(dir, "config.json"); + writeFileSync(configPath, "{}\n", "utf8"); + for (const [name, contents] of Object.entries(files)) { + writeFileSync(path.join(dir, name), contents, "utf8"); + } + return { dir, configPath }; +} + +function verifiedManifest(overrides: Record = {}) { + return JSON.stringify({ + version: 2, + state: "verified", + phase: "complete", + seedMode: "minimal", + ...overrides, + }); +} + +/** A `db` stand-in whose clone/identity probes both find one row. */ +function readyDb() { + return { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + // Both readiness probes are `limit(1)` existence queries, so one row is enough + // to answer either of them. + select: vi.fn(() => { + const result = [{ companyId: "company-1", userId: "user-1" }]; + const chain = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + where: vi.fn(() => chain), + limit: vi.fn(() => Promise.resolve(result)), + then: (resolve: (rows: unknown) => unknown) => Promise.resolve(result).then(resolve), + }; + return chain; + }), + } as unknown as Db; +} + +function handoffSubjectDb(rows: Array<{ userId: string; email: string | null }>) { + return { + select: vi.fn(() => { + const chain = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + then: (resolve: (value: unknown) => unknown) => Promise.resolve(rows).then(resolve), + }; + return chain; + }), + } as unknown as Db; +} + +function readyGuestEnv(configPath: string, overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + PAPERCLIP_CONFIG: configPath, + [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "key", + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1", + [WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: "company-1", + ...overrides, + }; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + resetManagedWorkspaceInstanceCacheForTests(); + vi.restoreAllMocks(); +}); + +describe("resolveWorkspaceReadinessState", () => { + const ready = { + databaseReady: true, + cloneDataReady: true, + authHandoffReady: true, + seedState: "verified", + } as const; + + it("is ready only when every signal agrees and the seed is verified", () => { + expect(resolveWorkspaceReadinessState(ready)).toBe("ready"); + expect(resolveWorkspaceReadinessState({ ...ready, seedState: "unknown" })).toBe("validating"); + expect(resolveWorkspaceReadinessState({ ...ready, seedState: "absent" })).toBe("validating"); + }); + + it("reports an unfinished clone as provisioning, not degraded", () => { + expect(resolveWorkspaceReadinessState({ ...ready, seedState: "pending", databaseReady: false })).toBe("provisioning"); + expect(resolveWorkspaceReadinessState({ ...ready, seedState: "running" })).toBe("provisioning"); + }); + + it("reports a regressed verified clone as degraded", () => { + expect(resolveWorkspaceReadinessState({ ...ready, databaseReady: false })).toBe("degraded"); + expect(resolveWorkspaceReadinessState({ ...ready, cloneDataReady: false })).toBe("degraded"); + expect(resolveWorkspaceReadinessState({ ...ready, authHandoffReady: false })).toBe("degraded"); + }); + + it("reports a recorded seed failure as failed", () => { + expect(resolveWorkspaceReadinessState({ ...ready, seedState: "failed" })).toBe("failed"); + }); +}); + +describe("resolveWorkspaceReadiness", () => { + it("reports a verified, fully readable clone as ready", async () => { + const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() }); + const readiness = await resolveWorkspaceReadiness({ + db: readyDb(), + env: readyGuestEnv(configPath), + }); + expect(readiness).toMatchObject({ + state: "ready", + databaseReady: true, + cloneDataReady: true, + authHandoffReady: true, + authHandoffUserId: null, + seedState: "verified", + seedMode: "minimal", + executionWorkspaceId: "ews-1", + companyId: "company-1", + failurePhase: null, + }); + }); + + it("does not claim the handoff is ready without a signing key", async () => { + const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() }); + const readiness = await resolveWorkspaceReadiness({ + db: readyDb(), + env: readyGuestEnv(configPath, { [WORKSPACE_HANDOFF_KEY_ENV_KEY]: undefined }), + }); + expect(readiness.authHandoffReady).toBe(false); + expect(readiness.state).toBe("degraded"); + expect(readiness.failurePhase).toBe("auth_handoff_not_configured"); + }); + + it("fails closed when the guest has no company binding", async () => { + const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() }); + const db = readyDb(); + const readiness = await resolveWorkspaceReadiness({ + db, + env: readyGuestEnv(configPath, { + [WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: undefined, + }), + }); + expect(readiness).toMatchObject({ + state: "degraded", + cloneDataReady: false, + authHandoffReady: false, + failurePhase: "workspace_company_not_configured", + }); + expect(db.select).not.toHaveBeenCalled(); + }); + + it("does not claim handoff readiness without the execution-workspace identity", async () => { + const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() }); + const readiness = await resolveWorkspaceReadiness({ + db: readyDb(), + env: readyGuestEnv(configPath, { + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: undefined, + }), + }); + expect(readiness).toMatchObject({ + state: "degraded", + cloneDataReady: true, + authHandoffReady: false, + failurePhase: "workspace_identity_not_configured", + }); + }); + + it("surfaces the failing seed phase instead of an inferred one", async () => { + const { configPath } = createMarkerDir({ + "seed-manifest.json": verifiedManifest({ state: "failed", phase: "restore" }), + }); + const readiness = await resolveWorkspaceReadiness({ + db: readyDb(), + env: readyGuestEnv(configPath), + }); + expect(readiness).toMatchObject({ state: "failed", seedState: "failed", failurePhase: "restore" }); + }); + + it("treats an unreadable manifest as a failure rather than assuming it seeded", async () => { + const { configPath } = createMarkerDir({ "seed-manifest.json": "{ truncated" }); + const readiness = await resolveWorkspaceReadiness({ + db: readyDb(), + env: readyGuestEnv(configPath), + }); + expect(readiness).toMatchObject({ state: "failed", failurePhase: "seed_manifest_unreadable" }); + }); + + it("reports an interrupted seed as provisioning", async () => { + const { configPath } = createMarkerDir({ "seed-pending": "{}" }); + const readiness = await resolveWorkspaceReadiness({ + db: readyDb(), + env: readyGuestEnv(configPath), + }); + expect(readiness).toMatchObject({ state: "provisioning", seedState: "pending" }); + }); + + it("reports a legacy marker clone as validating, never verified", async () => { + const { configPath } = createMarkerDir({ "seed-complete": "" }); + const readiness = await resolveWorkspaceReadiness({ + db: readyDb(), + env: readyGuestEnv(configPath), + }); + expect(readiness).toMatchObject({ state: "validating", seedState: "unknown" }); + }); + + it("reports an unreachable database without throwing", async () => { + const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() }); + const db = { + execute: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")), + select: vi.fn(), + } as unknown as Db; + const readiness = await resolveWorkspaceReadiness({ + db, + env: readyGuestEnv(configPath), + }); + expect(readiness).toMatchObject({ + state: "degraded", + databaseReady: false, + failurePhase: "database_unreachable", + }); + }); + + it("resolves the marker directory from the configured config path", () => { + const { dir, configPath } = createMarkerDir(); + expect(resolveWorkspaceSeedMarkerDir({ PAPERCLIP_CONFIG: configPath })).toBe(dir); + }); + + it("does not re-stat marker files on every health request", () => { + const { configPath } = createMarkerDir({ "seed-manifest.json": verifiedManifest() }); + const env = { PAPERCLIP_CONFIG: configPath }; + let clock = 0; + expect(isManagedWorkspaceInstance(env, () => clock)).toBe(true); + + // Removing the marker inside the TTL keeps the cached answer; past it, the + // filesystem is consulted again. + rmSync(path.join(path.dirname(configPath), "seed-manifest.json")); + clock = 1_000; + expect(isManagedWorkspaceInstance(env, () => clock)).toBe(true); + clock = 10_000; + expect(isManagedWorkspaceInstance(env, () => clock)).toBe(false); + }); + + it("only treats a process with clone evidence as a managed workspace", () => { + const { configPath } = createMarkerDir(); + expect(isManagedWorkspaceInstance({ PAPERCLIP_CONFIG: configPath })).toBe(false); + expect( + isManagedWorkspaceInstance({ PAPERCLIP_CONFIG: configPath, [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "key" }), + ).toBe(true); + const seeded = createMarkerDir({ "seed-manifest.json": verifiedManifest() }); + expect(isManagedWorkspaceInstance({ PAPERCLIP_CONFIG: seeded.configPath })).toBe(true); + }); +}); + +describe("probeManagedWorkspaceReadiness", () => { + const identity: ManagedWorkspaceIdentity = { + instanceId: "instance-a", + executionWorkspaceId: "ews-1", + companyId: "company-1", + handoffKey: "handoff-key", + readinessToken: "probe-token", + secretSource: "derived", + }; + + function respond(body: unknown, init: { status?: number } = {}) { + return vi.fn(async () => + new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "content-type": "application/json" }, + }), + ) as unknown as typeof fetch; + } + + const readyPayload = { + status: "ok", + workspace: { + state: "ready", + databaseReady: true, + cloneDataReady: true, + authHandoffReady: true, + authHandoffUserId: null, + seedState: "verified", + seedPhase: "complete", + seedMode: "minimal", + instanceId: "instance-a", + executionWorkspaceId: "ews-1", + companyId: "company-1", + failurePhase: null, + }, + }; + + it("accepts a ready workspace and sends the derived probe token", async () => { + const fetchImpl = respond(readyPayload); + const result = await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl, + }); + expect(result.ok).toBe(true); + const [, init] = (fetchImpl as unknown as ReturnType).mock.calls[0]!; + expect((init as RequestInit & { headers: Record }).headers[WORKSPACE_READINESS_TOKEN_HEADER]) + .toBe("probe-token"); + }); + + it("binds a caller-scoped readiness probe to the exact handoff user", async () => { + const fetchImpl = respond({ + ...readyPayload, + workspace: { ...readyPayload.workspace, authHandoffUserId: "user-1" }, + }); + const result = await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + handoffSubject: { userId: "user-1", email: "operator@example.com" }, + fetchImpl, + }); + expect(result.ok).toBe(true); + const [, init] = (fetchImpl as unknown as ReturnType).mock.calls[0]!; + expect((init as RequestInit & { headers: Record }).headers).toMatchObject({ + [WORKSPACE_READINESS_USER_ID_HEADER]: "user-1", + [WORKSPACE_READINESS_USER_EMAIL_HEADER]: "operator@example.com", + }); + }); + + it("rejects a readiness response scoped to another handoff user", async () => { + expect( + await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + handoffSubject: { userId: "user-1", email: "operator@example.com" }, + fetchImpl: respond({ + ...readyPayload, + workspace: { ...readyPayload.workspace, authHandoffUserId: "user-other" }, + }), + }), + ).toMatchObject({ ok: false, reason: "identity_mismatch" }); + }); + + it("proves every active control-plane board identity before publication", async () => { + const db = handoffSubjectDb([ + { userId: "user-1", email: "one@example.com" }, + { userId: "user-2", email: "two@example.com" }, + ]); + await expect(listManagedWorkspaceHandoffSubjects(db, "company-1")).resolves.toEqual([ + { userId: "user-1", email: "one@example.com" }, + { userId: "user-2", email: "two@example.com" }, + ]); + + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const headers = init?.headers as Record; + return new Response(JSON.stringify({ + ...readyPayload, + workspace: { + ...readyPayload.workspace, + authHandoffUserId: headers[WORKSPACE_READINESS_USER_ID_HEADER], + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + await expect(probeManagedWorkspaceHandoffSubjects({ + db, + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl, + })).resolves.toMatchObject({ ok: true }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("blocks publication when no current board identity can use the handoff", async () => { + const fetchImpl = vi.fn() as unknown as typeof fetch; + await expect(probeManagedWorkspaceHandoffSubjects({ + db: handoffSubjectDb([]), + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl, + })).resolves.toMatchObject({ ok: false, reason: "not_ready" }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("rejects a workspace serving another instance, workspace, or company", async () => { + const wrongInstance = await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: respond({ + ...readyPayload, + workspace: { ...readyPayload.workspace, instanceId: "instance-b" }, + }), + }); + expect(wrongInstance).toMatchObject({ ok: false, reason: "identity_mismatch" }); + + const wrongWorkspace = await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: respond({ + ...readyPayload, + workspace: { ...readyPayload.workspace, executionWorkspaceId: "ews-other" }, + }), + }); + expect(wrongWorkspace).toMatchObject({ ok: false, reason: "identity_mismatch" }); + + const wrongCompany = await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: respond({ + ...readyPayload, + workspace: { ...readyPayload.workspace, companyId: "company-other" }, + }), + }); + expect(wrongCompany).toMatchObject({ ok: false, reason: "identity_mismatch" }); + }); + + it("rejects a partial readiness contract with no company identity", async () => { + const { companyId: _companyId, ...partialReadiness } = readyPayload.workspace; + expect( + await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: respond({ ...readyPayload, workspace: partialReadiness }), + }), + ).toMatchObject({ ok: false, reason: "identity_mismatch", readiness: null }); + }); + + it("rejects a 200 response whose payload is not semantically healthy", async () => { + expect( + await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: respond({ status: "unhealthy" }), + }), + ).toMatchObject({ ok: false, reason: "unhealthy_payload" }); + }); + + it("rejects a response with no readiness block, so a legacy guest cannot publish", async () => { + expect( + await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: respond({ status: "ok" }), + }), + ).toMatchObject({ ok: false, reason: "readiness_missing" }); + }); + + it("rejects a workspace whose clone or handoff is not ready and keeps the failure phase", async () => { + expect( + await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: respond({ + ...readyPayload, + workspace: { + ...readyPayload.workspace, + state: "degraded", + cloneDataReady: false, + failurePhase: "clone_data_missing", + }, + }), + }), + ).toMatchObject({ ok: false, reason: "not_ready", detail: "clone_data_missing" }); + }); + + it("reports an unreachable guest", async () => { + const result = await probeManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:42013/api/health", + identity, + fetchImpl: (async () => { + throw new Error("connect ECONNREFUSED"); + }) as unknown as typeof fetch, + }); + expect(result).toMatchObject({ ok: false, reason: "unreachable" }); + }); + + it("fails closed when any part of the workspace identity is unresolved", () => { + // Every readiness and handoff check keys off this. Returning a partial identity + // would silently downgrade the whole gate to the legacy transport check, so the + // three inputs are all required. + const complete = { + workspaceCwd: "/srv/worktree", + executionWorkspaceId: "ews-1", + companyId: "company-1", + env: { PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "root" }, + }; + expect(resolveManagedWorkspaceIdentity(complete)).not.toBeNull(); + expect(resolveManagedWorkspaceIdentity({ ...complete, companyId: null })).toBeNull(); + expect(resolveManagedWorkspaceIdentity({ ...complete, executionWorkspaceId: null })).toBeNull(); + expect(resolveManagedWorkspaceIdentity({ ...complete, workspaceCwd: null })).toBeNull(); + expect(resolveManagedWorkspaceIdentity({ ...complete, env: {} })).toBeNull(); + }); + + it("derives distinct key material per company on the same workspace", () => { + const base = { + workspaceCwd: "/srv/worktree", + executionWorkspaceId: "ews-1", + env: { PAPERCLIP_WORKSPACE_HANDOFF_SECRET: "root" }, + }; + const first = resolveManagedWorkspaceIdentity({ ...base, companyId: "company-1" }); + const second = resolveManagedWorkspaceIdentity({ ...base, companyId: "company-2" }); + // The signing key is deliberately per instance+workspace, not per company — + // the company is enforced by the signed `cid` claim instead — so the keys match + // while the recorded company differs. + expect(first?.handoffKey).toBe(second?.handoffKey); + expect(first?.companyId).toBe("company-1"); + expect(second?.companyId).toBe("company-2"); + }); + + it("hands the guest only derived per-workspace material", () => { + expect(buildManagedWorkspaceGuestEnv(identity)).toEqual({ + [WORKSPACE_HANDOFF_KEY_ENV_KEY]: "handoff-key", + [WORKSPACE_READINESS_TOKEN_ENV_KEY]: "probe-token", + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: "ews-1", + PAPERCLIP_EXECUTION_WORKSPACE_COMPANY_ID: "company-1", + }); + }); +}); + +describe("shouldBlockPublicationOnReadiness", () => { + function rejection(reason: Extract["reason"]) { + return { ok: false as const, reason, readiness: null, detail: null }; + } + + it("blocks on any real disagreement about the clone", () => { + for (const reason of ["unreachable", "http_error", "unhealthy_payload", "not_ready", "identity_mismatch"] as const) { + expect(shouldBlockPublicationOnReadiness(rejection(reason), "auto")).toBe(true); + expect(shouldBlockPublicationOnReadiness(rejection(reason), "strict")).toBe(true); + } + }); + + it("does not turn an upgrade lag into an outage by default", () => { + expect(shouldBlockPublicationOnReadiness(rejection("readiness_missing"), "auto")).toBe(false); + expect(shouldBlockPublicationOnReadiness(rejection("readiness_missing"), "strict")).toBe(true); + }); + + it("reads the deployment-level mode from the environment", () => { + expect(resolveWorkspaceReadinessGateMode({})).toBe("auto"); + expect(resolveWorkspaceReadinessGateMode({ PAPERCLIP_WORKSPACE_READINESS_GATE: "STRICT" })).toBe("strict"); + expect(resolveWorkspaceReadinessGateMode({ PAPERCLIP_WORKSPACE_READINESS_GATE: "anything-else" })).toBe("auto"); + }); +}); + +describe("waitForManagedWorkspaceReadiness", () => { + const identity: ManagedWorkspaceIdentity = { + instanceId: "instance-a", + executionWorkspaceId: "ews-1", + companyId: "company-1", + handoffKey: "k", + readinessToken: "t", + secretSource: "derived", + }; + + const ready = { + status: "ok", + workspace: { + state: "ready", + databaseReady: true, + cloneDataReady: true, + authHandoffReady: true, + authHandoffUserId: null, + seedState: "verified", + seedPhase: "complete", + seedMode: "minimal", + instanceId: "instance-a", + executionWorkspaceId: "ews-1", + companyId: "company-1", + failurePhase: null, + }, + }; + + it("absorbs a guest that becomes ready a beat after its listener", async () => { + let attempt = 0; + const fetchImpl = (async () => { + attempt += 1; + const body = attempt < 3 + ? { ...ready, workspace: { ...ready.workspace, databaseReady: false, state: "validating" } } + : ready; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; + + let clock = 0; + const result = await waitForManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:1/api/health", + identity, + fetchImpl, + now: () => clock, + sleep: async (ms) => { + clock += ms; + }, + }); + expect(result.ok).toBe(true); + expect(attempt).toBe(3); + }); + + it("does not poll a guest that has no readiness contract to satisfy", async () => { + let attempt = 0; + const fetchImpl = (async () => { + attempt += 1; + return new Response(JSON.stringify({ status: "ok" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as unknown as typeof fetch; + + const result = await waitForManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:1/api/health", + identity, + fetchImpl, + now: () => 0, + sleep: async () => undefined, + }); + expect(result).toMatchObject({ ok: false, reason: "readiness_missing" }); + expect(attempt).toBe(1); + }); + + it("gives up immediately when another instance owns the port", async () => { + let attempt = 0; + const fetchImpl = (async () => { + attempt += 1; + return new Response( + JSON.stringify({ ...ready, workspace: { ...ready.workspace, instanceId: "someone-else" } }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch; + + const result = await waitForManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:1/api/health", + identity, + fetchImpl, + now: () => 0, + sleep: async () => undefined, + }); + expect(result).toMatchObject({ ok: false, reason: "identity_mismatch" }); + expect(attempt).toBe(1); + }); + + it("stops at the gate budget instead of holding a start open forever", async () => { + let attempt = 0; + const fetchImpl = (async () => { + attempt += 1; + throw new Error("connect ECONNREFUSED"); + }) as unknown as typeof fetch; + + let clock = 0; + const result = await waitForManagedWorkspaceReadiness({ + healthUrl: "http://127.0.0.1:1/api/health", + identity, + fetchImpl, + timeoutMs: 1_000, + now: () => clock, + sleep: async (ms) => { + clock += ms; + }, + }); + expect(result).toMatchObject({ ok: false, reason: "unreachable" }); + // 1s budget at the 250ms retry interval: four probes, then the budget is spent. + expect(attempt).toBe(5); + }); +}); 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/__tests__/workspace-runtime-routes-authz.test.ts b/server/src/__tests__/workspace-runtime-routes-authz.test.ts index 3b7906cd867..64bfce7f9c2 100644 --- a/server/src/__tests__/workspace-runtime-routes-authz.test.ts +++ b/server/src/__tests__/workspace-runtime-routes-authz.test.ts @@ -54,7 +54,7 @@ vi.mock("../services/index.js", () => ({ secretService: () => mockSecretService, workspaceOperationService: () => mockWorkspaceOperationService, workspaceRuntimeLeaseService: () => mockWorkspaceRuntimeLeaseService, - LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"], + LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"], })); vi.mock("../services/workspace-runtime.js", () => ({ @@ -84,7 +84,7 @@ function registerWorkspaceRouteMocks() { secretService: () => mockSecretService, workspaceOperationService: () => mockWorkspaceOperationService, workspaceRuntimeLeaseService: () => mockWorkspaceRuntimeLeaseService, - LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart"], + LEASED_WORKSPACE_RUNTIME_ACTIONS: ["start", "stop", "restart", "repair"], })); vi.doMock("../services/workspace-runtime.js", () => ({ diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 6d33393bb0b..0c366a84d98 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -134,6 +134,17 @@ async function runPnpm(cwd: string, args: string[]) { await execFileAsync("pnpm", args, { cwd }); } +async function writeRegisteredSourceConfig(baseCwd: string, instanceId = "source-instance") { + const configDir = path.join(baseCwd, ".paperclip"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile(path.join(configDir, "config.json"), "{}\n", "utf8"); + await fs.writeFile( + path.join(configDir, ".env"), + `PAPERCLIP_INSTANCE_ID=${instanceId}\n`, + "utf8", + ); +} + async function createTempRepo(defaultBranch = "main") { const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-")); await runGit(repoRoot, ["init"]); @@ -437,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, @@ -446,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", ); @@ -455,6 +475,41 @@ describe("resolveRuntimeProvisionCommand", () => { })).toBe("./custom-provision.sh"); await fs.writeFile(path.join(cwd, ".paperclip", "seed-complete"), "{}\n"); + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( + "bash ./scripts/provision-worktree-runtime.sh", + ); + + await fs.writeFile( + path.join(cwd, ".paperclip", "seed-manifest.json"), + JSON.stringify({ version: 2, state: "failed" }), + ); + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( + "bash ./scripts/provision-worktree-runtime.sh", + ); + await fs.writeFile( + path.join(cwd, ".paperclip", "seed-manifest.json"), + JSON.stringify({ version: 2, state: "verified" }), + ); + expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe( + "bash ./scripts/provision-worktree-runtime.sh", + ); + await fs.writeFile( + path.join(cwd, ".paperclip", "seed-manifest.json"), + JSON.stringify({ + version: 2, + source: { instanceId: "source", configPath: "/source/config.json" }, + snapshotAt: "2026-08-18T00:00:00.000Z", + seedMode: "full", + migrationRevision: "0001", + targetInstanceId: "target", + phase: "complete", + state: "verified", + attemptId: "attempt", + startedAt: "2026-08-18T00:00:00.000Z", + finishedAt: "2026-08-18T00:01:00.000Z", + diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-18T00:01:00.000Z" }], + }), + ); expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(""); } finally { await fs.rm(baseCwd, { recursive: true, force: true }); @@ -728,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(); @@ -1373,8 +1477,13 @@ describe("realizeExecutionWorkspace", () => { it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => { const repoRoot = await createTempRepo(); + await writeRegisteredSourceConfig(repoRoot, "worktree-base-source"); const previousCwd = process.cwd(); const previousPath = process.env.PATH; + const previousConfig = process.env.PAPERCLIP_CONFIG; + const previousHome = process.env.PAPERCLIP_HOME; + const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID; + const previousWorktreesDir = process.env.PAPERCLIP_WORKTREES_DIR; const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-")); const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-")); const isolatedBin = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bin-")); @@ -1386,6 +1495,7 @@ describe("realizeExecutionWorkspace", () => { process.env.PAPERCLIP_HOME = paperclipHome; process.env.PAPERCLIP_INSTANCE_ID = instanceId; process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome; + delete process.env.PAPERCLIP_CONFIG; // Keep this server-side fixture on provision-worktree.sh's config writer path; // CLI/database seeding is covered by the CLI worktree tests. await fs.symlink(process.execPath, path.join(isolatedBin, "node")); @@ -1557,6 +1667,18 @@ describe("realizeExecutionWorkspace", () => { } else { process.env.PATH = previousPath; } + for (const [key, value] of [ + ["PAPERCLIP_CONFIG", previousConfig], + ["PAPERCLIP_HOME", previousHome], + ["PAPERCLIP_INSTANCE_ID", previousInstanceId], + ["PAPERCLIP_WORKTREES_DIR", previousWorktreesDir], + ] as const) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } } }, 15_000); @@ -1564,6 +1686,7 @@ describe("realizeExecutionWorkspace", () => { "provisions worktree-local pnpm node_modules instead of reusing base-repo links", async () => { const repoRoot = await createTempRepo(); + await writeRegisteredSourceConfig(repoRoot); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); @@ -1666,6 +1789,7 @@ describe("realizeExecutionWorkspace", () => { it("provisions successfully when install is needed but there are no symlinked node_modules to move", async () => { const repoRoot = await createTempRepo(); + await writeRegisteredSourceConfig(repoRoot); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); await fs.writeFile( path.join(repoRoot, "package.json"), @@ -1748,6 +1872,7 @@ describe("realizeExecutionWorkspace", () => { try { await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true }); + await writeRegisteredSourceConfig(baseRoot); await fs.mkdir(path.join(worktreeRoot, "node_modules"), { recursive: true }); await fs.mkdir(path.join(worktreeRoot, "ui"), { recursive: true }); await fs.mkdir(fakeBin, { recursive: true }); @@ -1840,6 +1965,7 @@ describe("realizeExecutionWorkspace", () => { try { await fs.mkdir(baseRoot, { recursive: true }); + await writeRegisteredSourceConfig(baseRoot); await fs.mkdir(worktreeRoot, { recursive: true }); await fs.mkdir(fakeBin, { recursive: true }); await fs.copyFile(provisionWorktreeScriptPath, scriptPath); @@ -1897,6 +2023,7 @@ describe("realizeExecutionWorkspace", () => { try { await fs.mkdir(baseRoot, { recursive: true }); + await writeRegisteredSourceConfig(baseRoot); await fs.mkdir(paperclipDir, { recursive: true }); await fs.mkdir(fakeBin, { recursive: true }); await fs.copyFile(provisionWorktreeScriptPath, scriptPath); @@ -1987,6 +2114,7 @@ describe("realizeExecutionWorkspace", () => { try { await fs.mkdir(path.join(baseRoot, "node_modules"), { recursive: true }); + await writeRegisteredSourceConfig(baseRoot); await fs.mkdir(worktreeRoot, { recursive: true }); await fs.mkdir(fakeBin, { recursive: true }); await fs.copyFile(provisionWorktreeScriptPath, scriptPath); @@ -2056,6 +2184,7 @@ describe("realizeExecutionWorkspace", () => { "provisions worktree-local pnpm node_modules instead of reusing base-repo links", async () => { const repoRoot = await createTempRepo(); + await writeRegisteredSourceConfig(repoRoot); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); @@ -3813,6 +3942,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", + metadata: expect.objectContaining({ + provisionKind: "runtime_dependencies", + }), + result: expect.objectContaining({ + status: "succeeded", + }), + }), + ]); + } 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"); @@ -3919,6 +4149,86 @@ describe("ensureRuntimeServicesForRun", () => { } }); + it("replaces a reused Paperclip dev runtime whose 2xx health payload is unhealthy", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-misreported-health-")); + const workspace = buildWorkspace(workspaceRoot); + const serviceCommand = + "node -e \"let healthy=true;const http=require('node:http');http.createServer((req,res)=>{if(req.url==='/misreport'){healthy=false;res.end('failed');return;}if(req.url==='/api/health'){res.setHeader('content-type','application/json');res.end(JSON.stringify(healthy?{status:'ok'}:{status:'unhealthy',error:'database_unreachable'}));return;}res.end('ok')}).listen(Number(process.env.PORT),'127.0.0.1')\""; + const input = { + actor: { id: "agent-1", name: "Codex Coder", companyId: "company-1" }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-health", + config: { workspaceRuntime: { services: [{ + name: "paperclip-dev", + command: serviceCommand, + cwd: ".", + port: { type: "auto" as const }, + readiness: { type: "http" as const, urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 3, intervalMs: 100 }, + expose: { type: "url" as const, urlTemplate: "http://127.0.0.1:{{port}}" }, + lifecycle: "shared" as const, + stopPolicy: { type: "manual" as const }, + }] } }, + adapterEnv: {}, + }; + try { + const [first] = await startRuntimeServicesForWorkspaceControl(input); + await expect(fetch(`${first!.url}/misreport`)).resolves.toMatchObject({ ok: true }); + await expect(fetch(`${first!.url}/api/health`)).resolves.toMatchObject({ ok: true }); + const [[replacement], [concurrentReuse]] = await Promise.all([ + startRuntimeServicesForWorkspaceControl(input), + startRuntimeServicesForWorkspaceControl(input), + ]); + expect(replacement?.id).not.toBe(first?.id); + expect(replacement?.reused).toBe(false); + expect(concurrentReuse?.id).toBe(replacement?.id); + expect(concurrentReuse?.reused).toBe(true); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-health", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + + it("reuses a shared Paperclip dev runtime after one transient unhealthy response", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-transient-health-")); + const workspace = buildWorkspace(workspaceRoot); + const serviceCommand = + "node -e \"let failNext=false;const http=require('node:http');http.createServer((req,res)=>{if(req.url==='/fail-next'){failNext=true;res.end('armed');return;}if(req.url==='/api/health'){res.setHeader('content-type','application/json');const healthy=!failNext;failNext=false;res.end(JSON.stringify({status:healthy?'ok':'unhealthy'}));return;}res.end('ok')}).listen(Number(process.env.PORT),'127.0.0.1')\""; + const input = { + actor: { id: "agent-1", name: "Codex Coder", companyId: "company-1" }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-transient-health", + config: { workspaceRuntime: { services: [{ + name: "paperclip-dev", + command: serviceCommand, + cwd: ".", + port: { type: "auto" as const }, + readiness: { type: "http" as const, urlTemplate: "http://127.0.0.1:{{port}}", timeoutSec: 3, intervalMs: 100 }, + expose: { type: "url" as const, urlTemplate: "http://127.0.0.1:{{port}}" }, + lifecycle: "shared" as const, + stopPolicy: { type: "manual" as const }, + }] } }, + adapterEnv: {}, + }; + try { + const [first] = await startRuntimeServicesForWorkspaceControl(input); + await expect(fetch(`${first!.url}/fail-next`)).resolves.toMatchObject({ ok: true }); + const [reused] = await startRuntimeServicesForWorkspaceControl(input); + expect(reused?.id).toBe(first?.id); + expect(reused?.reused).toBe(true); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-transient-health", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + it("uses explicit readiness URL when exposed URL is not the local probe address", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-explicit-readiness-")); const workspace = buildWorkspace(workspaceRoot); @@ -4085,8 +4395,10 @@ describe("ensureRuntimeServicesForRun", () => { branchName: "PAP-874-chat-speed-issues", worktreePath: worktreeWorkspaceRoot, }; + // A Paperclip dev runtime must answer `/api/health` semantically before it may + // be published, so the fake serves the same shape a real one does. const serviceCommand = - "node -e \"require('node:http').createServer((req,res)=>res.end(process.env.PAPERCLIP_HOME)).listen(Number(process.env.PORT), '127.0.0.1')\""; + "node -e \"require('node:http').createServer((req,res)=>{if(req.url==='/api/health'){res.setHeader('content-type','application/json');res.end(JSON.stringify({status:'ok'}));return;}res.end(process.env.PAPERCLIP_HOME)}).listen(Number(process.env.PORT), '127.0.0.1')\""; const config = { workspaceRuntime: { services: [ @@ -5971,7 +6283,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () => const delayedHmrScript = [ "const http=require('node:http');", "const port=Number(process.env.PORT);", - "http.createServer((_req,res)=>res.end('ok')).listen(port,'127.0.0.1');", + "http.createServer((req,res)=>{if(req.url==='/api/health'){res.setHeader('content-type','application/json');res.end(JSON.stringify({status:'ok'}));return;}res.end('ok')}).listen(port,'127.0.0.1');", "setTimeout(()=>http.createServer((_req,res)=>res.end('hmr')).listen(port+10000,'127.0.0.1'),750);", "setInterval(()=>{},1000);", ].join(""); @@ -6803,7 +7115,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { const projectWorkspaceId = randomUUID(); // Binds the app port and its HMR companion, both loopback-only. const command = - "node -e \"const http=require('node:http');const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((req,res)=>res.end('ok')).listen(q,'127.0.0.1');setInterval(()=>{},1000)\""; + "node -e \"const http=require('node:http');const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((req,res)=>{if(req.url==='/api/health'){res.setHeader('content-type','application/json');res.end(JSON.stringify({status:'ok'}));return;}res.end('ok')}).listen(q,'127.0.0.1');setInterval(()=>{},1000)\""; const workspaceRuntime = { services: [ { @@ -6978,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; @@ -6987,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, @@ -7021,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({ @@ -7038,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", @@ -7063,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 }); @@ -7076,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-")); @@ -7112,7 +7640,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { const executionWorkspaceId = randomUUID(); const stoppedServiceId = randomUUID(); const serviceCommand = - "node -e \"const http=require('node:http'); const stale=process.env.STALE_HEALTH==='1'; http.createServer((req,res)=>{ if (req.url==='/api/health' && stale) { res.statusCode=503; res.end('database_unreachable'); return; } res.end('ok'); }).listen(Number(process.env.PORT), '127.0.0.1')\""; + "node -e \"const http=require('node:http'); const stale=process.env.STALE_HEALTH==='1'; http.createServer((req,res)=>{ if (req.url==='/api/health') { if (stale) { res.statusCode=503; res.end('database_unreachable'); return; } res.setHeader('content-type','application/json'); res.end(JSON.stringify({status:'ok'})); return; } res.end('ok'); }).listen(Number(process.env.PORT), '127.0.0.1')\""; const scopeType = "agent"; const scopeId = agentId; const reuseKey = createHash("sha256") 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/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/auth/better-auth.ts b/server/src/auth/better-auth.ts index 4f8d91b64a2..c367ffd3ac7 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -12,6 +12,16 @@ import { } from "@paperclipai/db"; import type { Config } from "../config.js"; import { resolvePaperclipInstanceId } from "../home-paths.js"; +import { + workspaceLoginHandoffPlugin, + type WorkspaceHandoffExpectedIdentity, +} from "./workspace-login-handoff-plugin.js"; +import { + normalizeWorkspaceHandoffOrigin, + resolveWorkspaceHandoffLocalCompanyId, + resolveWorkspaceHandoffLocalKey, + resolveWorkspaceHandoffLocalWorkspaceId, +} from "./workspace-login-handoff.js"; export type BetterAuthSessionUser = { id: string; @@ -144,6 +154,34 @@ export function deriveAuthTrustedOrigins(config: Config, opts?: { listenPort?: n return Array.from(trustedOrigins); } +/** + * Identity a managed workspace instance compares an inbound handoff ticket + * against. Every field comes from persisted configuration or injected runtime + * identity — never from request headers — so a spoofed `X-Forwarded-Host` or + * Tailscale identity header cannot retarget a ticket. Returns null when this + * process was not started as a managed workspace, which leaves the exchange + * endpoint unregistered. + */ +export function resolveWorkspaceHandoffIdentity( + config: Config, + env: NodeJS.ProcessEnv = process.env, +): WorkspaceHandoffExpectedIdentity | null { + const key = resolveWorkspaceHandoffLocalKey(env); + if (!key) return null; + const configuredOrigin = + normalizeWorkspaceHandoffOrigin(env.PAPERCLIP_PUBLIC_URL) + ?? (config.authBaseUrlMode === "explicit" + ? normalizeWorkspaceHandoffOrigin(config.authPublicBaseUrl) + : null); + return { + key, + instanceId: resolvePaperclipInstanceId(), + executionWorkspaceId: resolveWorkspaceHandoffLocalWorkspaceId(env), + companyId: resolveWorkspaceHandoffLocalCompanyId(env), + origin: configuredOrigin, + }; +} + export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: string[]): BetterAuthInstance { const baseUrl = config.authBaseUrlMode === "explicit" ? config.authPublicBaseUrl : undefined; const publicUrl = process.env.PAPERCLIP_PUBLIC_URL?.trim() || baseUrl; @@ -186,6 +224,28 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins: override: process.env.PAPERCLIP_AUTH_RATE_LIMIT_ENABLED, }), advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies }), + // Registered only for a managed workspace instance: the plugin is what makes + // `Open workspace` password-independent, and a control-plane instance that + // was never handed a workspace key must not expose the exchange at all. + ...(resolveWorkspaceHandoffIdentity(config) + ? { + plugins: [ + workspaceLoginHandoffPlugin({ + db, + // Re-resolved per exchange so a hot restart cannot keep validating + // against an origin the control plane has since republished. + resolveExpectedIdentity: () => + resolveWorkspaceHandoffIdentity(config) ?? { + key: null, + instanceId: null, + executionWorkspaceId: null, + companyId: null, + origin: null, + }, + }), + ], + } + : {}), }; if (!baseUrl) { diff --git a/server/src/auth/workspace-login-handoff-exchange.ts b/server/src/auth/workspace-login-handoff-exchange.ts new file mode 100644 index 00000000000..5a85cf176f9 --- /dev/null +++ b/server/src/auth/workspace-login-handoff-exchange.ts @@ -0,0 +1,127 @@ +/** + * Ticket exchange, isolated from Better Auth and Express so the whole + * security matrix (replay, expiry, wrong user/origin/workspace/instance, + * missing membership, spoofed forwarded headers) is unit-testable. + * + * Every dependency is injected. The caller supplies expected identity from + * *configuration*; this module never reads request headers, which is what makes + * forwarded-header spoofing structurally impossible rather than merely checked. + */ + +import { + sanitizeWorkspaceHandoffRedirectPath, + verifyWorkspaceHandoffTicket, + type WorkspaceHandoffTicketPayload, + type WorkspaceHandoffVerificationFailure, +} from "./workspace-login-handoff.js"; + +export type WorkspaceHandoffClonedIdentity = { + userId: string; + email: string | null; + name?: string | null; + /** + * Whether the cloned user has an active membership **in the ticket's company**. + * An unscoped "has some membership" answer would let the exchange hand out a + * session with no access to the board the caller was opening. + */ + hasActiveMembership: boolean; +}; + +export type WorkspaceHandoffExchangeDeps = { + /** Resolve the cloned user by the id the ticket names, scoped to its company. */ + findClonedIdentity: ( + input: { userId: string; companyId: string }, + ) => Promise; + /** + * Record the nonce, returning false when it was already recorded. + * Must be atomic: this is the only defense against a same-window replay. + */ + reserveNonce: (input: { nonce: string; expiresAt: Date }) => Promise; + /** Create an instance-scoped session for the verified cloned user. */ + createSession: (userId: string) => Promise; +}; + +export type WorkspaceHandoffExchangeFailureReason = + | WorkspaceHandoffVerificationFailure + | "replayed" + | "unknown_user" + | "user_mismatch" + | "missing_membership" + | "session_failed"; + +export type WorkspaceHandoffExchangeResult = + | { ok: true; redirectTo: string; payload: WorkspaceHandoffTicketPayload } + | { ok: false; reason: WorkspaceHandoffExchangeFailureReason; payload: WorkspaceHandoffTicketPayload | null }; + +/** + * HTTP status for a failed exchange. + * + * `not_configured` is the only server-fault case (the guest was started without + * a key); everything else is a rejected credential and answers 401 so an + * attacker cannot distinguish "wrong workspace" from "expired" by status alone. + */ +export function workspaceHandoffFailureStatus(reason: WorkspaceHandoffExchangeFailureReason): number { + if (reason === "not_configured") return 503; + if (reason === "session_failed") return 500; + return 401; +} + +/** + * Whether a failure means the workspace itself needs repair rather than the + * caller needing a fresh ticket. The UI turns these into an explicit + * "clone is incomplete" state instead of a generic sign-in error. + */ +export function isWorkspaceHandoffRepairReason(reason: WorkspaceHandoffExchangeFailureReason): boolean { + return reason === "unknown_user" || reason === "missing_membership" || reason === "user_mismatch"; +} + +export async function exchangeWorkspaceHandoffTicket(input: { + ticket: string | null | undefined; + key: string | null | undefined; + expected: { + instanceId: string | null; + executionWorkspaceId: string | null; + companyId: string | null; + origin: string | null; + }; + deps: WorkspaceHandoffExchangeDeps; + now?: Date; +}): Promise { + const verification = verifyWorkspaceHandoffTicket({ + ticket: input.ticket, + key: input.key, + expected: input.expected, + now: input.now, + }); + if (!verification.ok) return { ok: false, reason: verification.reason, payload: null }; + const payload = verification.payload; + + const identity = await input.deps.findClonedIdentity({ userId: payload.sub, companyId: payload.cid }); + if (!identity) return { ok: false, reason: "unknown_user", payload }; + // The email is bound into the signature, so a mismatch means the clone drifted + // from the source instance (a reused id after a reseed), not a forged ticket. + if ((identity.email ?? "").trim().toLowerCase() !== payload.email.trim().toLowerCase()) { + return { ok: false, reason: "user_mismatch", payload }; + } + if (!identity.hasActiveMembership) return { ok: false, reason: "missing_membership", payload }; + + // Reserve last: an identity failure must stay retryable after a repair, while a + // ticket that got far enough to mint a session is burned even if that mint fails. + const reserved = await input.deps.reserveNonce({ + nonce: payload.jti, + expiresAt: new Date(payload.exp * 1000), + }); + if (!reserved) return { ok: false, reason: "replayed", payload }; + + try { + await input.deps.createSession(identity.userId); + } catch { + return { ok: false, reason: "session_failed", payload }; + } + + return { + ok: true, + redirectTo: sanitizeWorkspaceHandoffRedirectPath(payload.next), + payload, + }; +} diff --git a/server/src/auth/workspace-login-handoff-plugin.ts b/server/src/auth/workspace-login-handoff-plugin.ts new file mode 100644 index 00000000000..46248b4922c --- /dev/null +++ b/server/src/auth/workspace-login-handoff-plugin.ts @@ -0,0 +1,222 @@ +/** + * Better Auth plugin that exchanges a signed workspace login handoff ticket for + * an instance-scoped session (PAP-17572). + * + * Implemented as a plugin endpoint rather than a hand-rolled Express route so + * session creation and cookie signing go through Better Auth's own supported + * path (`internalAdapter.createSession` + `setSessionCookie`) instead of this + * repository reimplementing cookie signing. + * + * The endpoint is only registered when the process was handed a per-workspace + * verification key, so a normal control-plane instance exposes no extra surface. + */ + +import { and, eq } from "drizzle-orm"; +import { setSessionCookie } from "better-auth/cookies"; +import { createAuthEndpoint } from "better-auth/api"; +import type { Session, User } from "better-auth/types"; +import type { Db } from "@paperclipai/db"; +import { companyMemberships } from "@paperclipai/db"; +import { logger } from "../middleware/logger.js"; +import { + exchangeWorkspaceHandoffTicket, + isWorkspaceHandoffRepairReason, + type WorkspaceHandoffExchangeFailureReason, +} from "./workspace-login-handoff-exchange.js"; +import { + WORKSPACE_HANDOFF_EXCHANGE_PATH, + WORKSPACE_HANDOFF_TICKET_QUERY_PARAM, + workspaceHandoffKeyFingerprint, +} from "./workspace-login-handoff.js"; + +/** + * Identity the guest expects, resolved from persisted configuration. + * + * Deliberately a callback: it is re-read per exchange so a hot-restarted guest + * cannot keep validating against a stale origin, and it keeps request headers + * out of the comparison entirely. + */ +export type WorkspaceHandoffExpectedIdentity = { + key: string | null; + instanceId: string | null; + executionWorkspaceId: string | null; + companyId: string | null; + origin: string | null; +}; + +/** + * The slice of Better Auth's endpoint context this exchange uses. + * + * Declared structurally rather than importing the library's deeply generic + * `GenericEndpointContext`, whose inference depends on the plugin's own option + * types. Narrowing here keeps the handler body fully type-checked; the single + * cast at the top of the handler is the only unchecked step. + */ +type WorkspaceHandoffEndpointContext = { + query?: Record; + setHeader: (name: string, value: string) => void; + redirect: (url: string) => unknown; + error: (status: string, body?: Record) => unknown; + context: { + internalAdapter: { + findUserById: (userId: string) => Promise; + createSession: (userId: string) => Promise; + findVerificationValue: (identifier: string) => Promise<{ identifier: string } | null>; + reserveVerificationValue: (data: { + identifier: string; + value: string; + expiresAt: Date; + }) => Promise; + }; + }; +}; + +/** Where a rejected ticket lands the browser: the labeled snapshot-local fallback. */ +function buildFallbackRedirect(input: { + baseUrl: string | undefined; + next: string; + reason: WorkspaceHandoffExchangeFailureReason; +}): string { + const target = new URL("/auth", input.baseUrl ?? "http://localhost"); + target.searchParams.set("next", input.next); + target.searchParams.set("workspaceHandoffError", input.reason); + return input.baseUrl ? target.toString() : `${target.pathname}${target.search}`; +} + +export function workspaceLoginHandoffPlugin(deps: { + db: Db; + resolveExpectedIdentity: () => WorkspaceHandoffExpectedIdentity; +}) { + return { + id: "paperclip-workspace-login-handoff", + endpoints: { + exchangeWorkspaceLoginHandoff: createAuthEndpoint( + WORKSPACE_HANDOFF_EXCHANGE_PATH, + { method: "GET", requireHeaders: true }, + async (endpointContext) => { + const ctx = endpointContext as unknown as WorkspaceHandoffEndpointContext; + const expected = deps.resolveExpectedIdentity(); + const rawTicket = ctx.query?.[WORKSPACE_HANDOFF_TICKET_QUERY_PARAM]; + const ticket = typeof rawTicket === "string" ? rawTicket : null; + + // `no-store` keeps the ticket-bearing response out of shared caches and + // `no-referrer` stops the ticket URL from leaking to the landing page. + ctx.setHeader("Cache-Control", "no-store"); + ctx.setHeader("Referrer-Policy", "no-referrer"); + + const result = await exchangeWorkspaceHandoffTicket({ + ticket, + key: expected.key, + expected: { + instanceId: expected.instanceId, + executionWorkspaceId: expected.executionWorkspaceId, + companyId: expected.companyId, + origin: expected.origin, + }, + deps: { + findClonedIdentity: async ({ userId, companyId }) => { + const user = await ctx.context.internalAdapter.findUserById(userId); + if (!user) return null; + // Membership lives in Paperclip's own schema, so it is read + // through the app's `db` handle rather than the Better Auth + // adapter. Scoped to the ticket's company: an unscoped check + // would accept a clone where this user belongs to some *other* + // company and hand them a session with no access to the board + // they opened. Existence, not a count. + const activeMemberships = await deps.db + .select({ id: companyMemberships.id }) + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + eq(companyMemberships.status, "active"), + ), + ) + .limit(1) + .then((rows) => rows.length); + return { + userId: user.id, + email: user.email ?? null, + name: user.name ?? null, + hasActiveMembership: activeMemberships > 0, + }; + }, + reserveNonce: async ({ nonce, expiresAt }) => { + const identifier = `workspace-login-handoff:${nonce}`; + // Two layers, because each covers a case the other misses. The + // lookup catches an ordinary sequential replay on any storage + // backend; the reservation is a primary-key insert, so it is the + // one that survives two exchanges racing inside the TTL. Relying + // on the reservation alone would silently depend on the + // configured adapter enforcing id uniqueness. + if (await ctx.context.internalAdapter.findVerificationValue(identifier)) return false; + return await ctx.context.internalAdapter.reserveVerificationValue({ + identifier, + value: "consumed", + // Keep the record slightly past the ticket's own expiry so a + // replay attempted at the very edge of the window still loses. + expiresAt: new Date(expiresAt.getTime() + 60_000), + }); + }, + createSession: async (userId) => { + const user = await ctx.context.internalAdapter.findUserById(userId); + if (!user) throw new Error("cloned user disappeared between verification and session creation"); + const session = await ctx.context.internalAdapter.createSession(userId); + if (!session) throw new Error("Better Auth did not return a session"); + await setSessionCookie(ctx as never, { session, user }); + }, + }, + }); + + if (!result.ok) { + // Audit with the nonce and reason but never the ticket itself. + logger.warn( + { + reason: result.reason, + nonce: result.payload?.jti ?? null, + executionWorkspaceId: expected.executionWorkspaceId, + instanceId: expected.instanceId, + keyFingerprint: expected.key ? workspaceHandoffKeyFingerprint(expected.key) : null, + needsRepair: isWorkspaceHandoffRepairReason(result.reason), + }, + "workspace login handoff rejected", + ); + if (result.reason === "not_configured" || result.reason === "session_failed") { + throw ctx.error(result.reason === "not_configured" ? "SERVICE_UNAVAILABLE" : "INTERNAL_SERVER_ERROR", { + message: "Workspace login handoff is unavailable", + code: result.reason, + }); + } + throw ctx.redirect( + buildFallbackRedirect({ + baseUrl: expected.origin ?? undefined, + next: result.payload?.next ?? "/", + reason: result.reason, + }), + ); + } + + logger.info( + { + nonce: result.payload.jti, + userId: result.payload.sub, + executionWorkspaceId: expected.executionWorkspaceId, + instanceId: expected.instanceId, + issuerInstanceId: result.payload.iss, + }, + "workspace login handoff accepted", + ); + // An HTTP redirect (not a client-side navigation) is what keeps the + // ticket URL out of the browser's session history. + throw ctx.redirect( + expected.origin + ? new URL(result.redirectTo, expected.origin).toString() + : result.redirectTo, + ); + }, + ), + }, + }; +} diff --git a/server/src/auth/workspace-login-handoff.ts b/server/src/auth/workspace-login-handoff.ts new file mode 100644 index 00000000000..593f98469c6 --- /dev/null +++ b/server/src/auth/workspace-login-handoff.ts @@ -0,0 +1,406 @@ +/** + * Signed workspace login handoff (PAP-17572). + * + * A board user opening a managed workspace must not have to know which cloned + * password is current. The authenticated main control plane mints a short-lived, + * single-use ticket; the isolated workspace verifies it against its own copy of + * the signing key and creates its own instance-scoped session. + * + * Threat model notes that drive the shape below: + * + * - **Nothing browser-supplied is trusted.** The audience is the origin the + * control plane published for that runtime and the workspace compares it + * against its *configured* public origin. `Host`, `X-Forwarded-Host`, + * `X-Forwarded-Proto` and Tailscale identity headers are never consulted, so + * a spoofed forwarded header cannot retarget or authorize a ticket. + * - **Keys are per workspace.** The control plane keeps the root secret and + * hands each guest only `HMAC(root, instanceId|executionWorkspaceId)`. A + * compromised workspace therefore cannot mint a ticket for a sibling + * workspace, and the guest never sees material that would let it do so. + * - **Replay is bounded twice.** A short expiry limits the window and the + * workspace records the nonce on first use, so a captured ticket cannot be + * exchanged a second time even inside that window. + */ + +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** Ticket envelope version. Bumped whenever the signed payload shape changes. */ +export const WORKSPACE_HANDOFF_TICKET_VERSION = "wh1"; + +/** + * Default ticket lifetime. Long enough for a click plus a cold guest response, + * short enough that a ticket leaked through a proxy log is worthless. + */ +export const WORKSPACE_HANDOFF_TICKET_TTL_SECONDS = 90; + +/** Tolerated clock skew between the control plane and the isolated workspace. */ +export const WORKSPACE_HANDOFF_CLOCK_SKEW_SECONDS = 30; + +/** Path the guest exposes for the exchange, relative to the Better Auth mount. */ +export const WORKSPACE_HANDOFF_EXCHANGE_PATH = "/workspace-handoff/exchange"; + +/** Query parameter carrying the ticket. Redacted from request logs by name. */ +export const WORKSPACE_HANDOFF_TICKET_QUERY_PARAM = "ticket"; + +const HANDOFF_KEY_ENV = "PAPERCLIP_WORKSPACE_HANDOFF_KEY"; +const HANDOFF_ROOT_SECRET_ENV = "PAPERCLIP_WORKSPACE_HANDOFF_SECRET"; +const READINESS_TOKEN_ENV = "PAPERCLIP_WORKSPACE_READINESS_TOKEN"; +const EXECUTION_WORKSPACE_ID_ENV = "PAPERCLIP_EXECUTION_WORKSPACE_ID"; +const EXECUTION_WORKSPACE_COMPANY_ID_ENV = "PAPERCLIP_EXECUTION_WORKSPACE_COMPANY_ID"; + +export const WORKSPACE_HANDOFF_KEY_ENV_KEY = HANDOFF_KEY_ENV; +export const WORKSPACE_READINESS_TOKEN_ENV_KEY = READINESS_TOKEN_ENV; +export const WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY = EXECUTION_WORKSPACE_ID_ENV; +export const WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY = EXECUTION_WORKSPACE_COMPANY_ID_ENV; +export const WORKSPACE_READINESS_TOKEN_HEADER = "x-paperclip-workspace-readiness-token"; +export const WORKSPACE_READINESS_USER_ID_HEADER = "x-paperclip-workspace-readiness-user-id"; +export const WORKSPACE_READINESS_USER_EMAIL_HEADER = "x-paperclip-workspace-readiness-user-email"; + +export type WorkspaceHandoffTicketPayload = { + /** Envelope version. */ + v: typeof WORKSPACE_HANDOFF_TICKET_VERSION; + /** Single-use nonce; the guest records it on first exchange. */ + jti: string; + /** Cloned user id the guest must sign in. */ + sub: string; + /** Expected email of that cloned user, compared case-insensitively. */ + email: string; + /** Execution workspace the ticket is scoped to. */ + ws: string; + /** + * Company whose board this workspace represents. + * + * Bound because "the cloned user has *some* active membership" is not the + * question worth asking: a multi-company clone can satisfy it while leaving the + * signed-in user with no access to the company they were opening, which lands + * them on an empty board with no explanation. + */ + cid: string; + /** Isolated instance id the ticket is scoped to. */ + iid: string; + /** Origin the control plane published for this runtime. */ + aud: string; + /** Issuing instance id, recorded for audit. */ + iss: string; + /** Seconds since epoch. */ + iat: number; + exp: number; + /** Relative path to land on after the exchange. */ + next: string; +}; + +export type WorkspaceHandoffVerificationFailure = + | "not_configured" + | "malformed" + | "unsupported_version" + | "bad_signature" + | "expired" + | "not_yet_valid" + | "origin_mismatch" + | "instance_mismatch" + | "workspace_mismatch" + | "company_mismatch"; + +export type WorkspaceHandoffVerificationResult = + | { ok: true; payload: WorkspaceHandoffTicketPayload } + | { ok: false; reason: WorkspaceHandoffVerificationFailure }; + +function base64UrlEncode(value: Buffer): string { + return value.toString("base64url"); +} + +function base64UrlDecode(value: string): Buffer | null { + // `Buffer.from` silently drops invalid characters, so round-trip to prove the + // input really was canonical base64url before trusting the bytes. + if (!/^[A-Za-z0-9_-]+$/.test(value)) return null; + const decoded = Buffer.from(value, "base64url"); + if (decoded.length === 0) return null; + return base64UrlEncode(decoded) === value ? decoded : null; +} + +function sign(key: string, message: string): Buffer { + return createHmac("sha256", key).update(message).digest(); +} + +function constantTimeMatches(expected: Buffer, provided: Buffer): boolean { + if (expected.length !== provided.length) return false; + return timingSafeEqual(expected, provided); +} + +/** + * Normalize an origin for comparison: scheme + host + explicit non-default port. + * Returns null when the input is not an absolute http(s) URL, so an unparseable + * or relative value can never accidentally compare equal. + */ +export function normalizeWorkspaceHandoffOrigin(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) return null; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + return parsed.origin; +} + +/** + * Reduce a caller-supplied landing target to a safe same-origin path. + * + * Anything absolute, protocol-relative, or backslash-escaped collapses to `/`. + * A handoff must never double as an open redirect. + */ +export function sanitizeWorkspaceHandoffRedirectPath(value: string | null | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) return "/"; + if (!trimmed.startsWith("/")) return "/"; + // `//host` and `/\host` are both browser-recognized protocol-relative forms. + if (trimmed.startsWith("//") || trimmed.startsWith("/\\")) return "/"; + // Control characters and raw whitespace can split a `Location` header. + if (/[\u0000-\u0020\u007f]/.test(trimmed)) return "/"; + return trimmed; +} + +/** + * Derive the per-workspace signing key handed to one guest instance. + * + * Domain-separated from every other use of the root secret and bound to both + * identities, so a key only ever validates tickets for the exact workspace it + * was issued to. + */ +export function deriveWorkspaceHandoffKey(input: { + rootSecret: string; + instanceId: string; + executionWorkspaceId: string; +}): string { + return createHmac("sha256", input.rootSecret) + .update(`paperclip.workspace-login-handoff.${WORKSPACE_HANDOFF_TICKET_VERSION}\n`) + .update(`${input.instanceId}\n`) + .update(`${input.executionWorkspaceId}`) + .digest("hex"); +} + +/** + * Derive the bearer token the control plane presents to read a guest's + * protected readiness. Separate derivation from {@link deriveWorkspaceHandoffKey} + * so a probe token that leaks through an HTTP client log cannot sign tickets. + */ +export function deriveWorkspaceReadinessToken(input: { + rootSecret: string; + instanceId: string; + executionWorkspaceId: string; +}): string { + return createHmac("sha256", input.rootSecret) + .update(`paperclip.workspace-readiness-probe.${WORKSPACE_HANDOFF_TICKET_VERSION}\n`) + .update(`${input.instanceId}\n`) + .update(`${input.executionWorkspaceId}`) + .digest("hex"); +} + +/** + * Root secret for handoff key derivation, on the control-plane side only. + * + * A dedicated `PAPERCLIP_WORKSPACE_HANDOFF_SECRET` is preferred. When it is + * absent we derive dedicated key material from the instance's existing signing + * secret rather than disabling the feature: the alternative is that every + * already-deployed instance silently falls back to "remember the cloned + * password", which is the defect this work exists to remove. The derivation is + * one-way and domain-separated, so the handoff key is never equal to — and + * cannot be used to recover — the secret it came from. + */ +export function resolveWorkspaceHandoffRootSecret( + env: NodeJS.ProcessEnv = process.env, +): { secret: string; source: "dedicated" | "derived" } | null { + const dedicated = env[HANDOFF_ROOT_SECRET_ENV]?.trim(); + if (dedicated) return { secret: dedicated, source: "dedicated" }; + + const fallback = env.BETTER_AUTH_SECRET?.trim() || env.PAPERCLIP_AGENT_JWT_SECRET?.trim(); + if (!fallback) return null; + return { + secret: createHmac("sha256", fallback) + .update(`paperclip.workspace-login-handoff.root.${WORKSPACE_HANDOFF_TICKET_VERSION}`) + .digest("hex"), + source: "derived", + }; +} + +/** The verification key injected into this (guest) process, when present. */ +export function resolveWorkspaceHandoffLocalKey(env: NodeJS.ProcessEnv = process.env): string | null { + return env[HANDOFF_KEY_ENV]?.trim() || null; +} + +/** The readiness-probe token injected into this (guest) process, when present. */ +export function resolveWorkspaceReadinessLocalToken(env: NodeJS.ProcessEnv = process.env): string | null { + return env[READINESS_TOKEN_ENV]?.trim() || null; +} + +/** The execution workspace this (guest) process was provisioned for. */ +export function resolveWorkspaceHandoffLocalWorkspaceId(env: NodeJS.ProcessEnv = process.env): string | null { + return env[EXECUTION_WORKSPACE_ID_ENV]?.trim() || null; +} + +/** The company whose board this (guest) workspace represents. */ +export function resolveWorkspaceHandoffLocalCompanyId(env: NodeJS.ProcessEnv = process.env): string | null { + return env[EXECUTION_WORKSPACE_COMPANY_ID_ENV]?.trim() || null; +} + +export function issueWorkspaceHandoffTicket(input: { + key: string; + userId: string; + email: string; + executionWorkspaceId: string; + companyId: string; + instanceId: string; + origin: string; + issuerInstanceId: string; + next?: string | null; + ttlSeconds?: number; + now?: Date; + nonce?: string; +}): { ticket: string; payload: WorkspaceHandoffTicketPayload } { + const origin = normalizeWorkspaceHandoffOrigin(input.origin); + if (!origin) { + throw new Error("Workspace login handoff needs an absolute http(s) target origin"); + } + const issuedAtMs = (input.now ?? new Date()).getTime(); + const issuedAt = Math.floor(issuedAtMs / 1000); + const ttlSeconds = Math.max(1, Math.floor(input.ttlSeconds ?? WORKSPACE_HANDOFF_TICKET_TTL_SECONDS)); + const payload: WorkspaceHandoffTicketPayload = { + v: WORKSPACE_HANDOFF_TICKET_VERSION, + jti: input.nonce ?? randomBytes(18).toString("base64url"), + sub: input.userId, + email: input.email, + ws: input.executionWorkspaceId, + cid: input.companyId, + iid: input.instanceId, + aud: origin, + iss: input.issuerInstanceId, + iat: issuedAt, + exp: issuedAt + ttlSeconds, + next: sanitizeWorkspaceHandoffRedirectPath(input.next), + }; + const encodedPayload = base64UrlEncode(Buffer.from(JSON.stringify(payload), "utf8")); + const signedMessage = `${WORKSPACE_HANDOFF_TICKET_VERSION}.${encodedPayload}`; + const signature = base64UrlEncode(sign(input.key, signedMessage)); + return { ticket: `${signedMessage}.${signature}`, payload }; +} + +/** Absolute exchange URL for a minted ticket. */ +export function buildWorkspaceHandoffExchangeUrl(input: { origin: string; ticket: string }): string { + const origin = normalizeWorkspaceHandoffOrigin(input.origin); + if (!origin) throw new Error("Workspace login handoff needs an absolute http(s) target origin"); + const url = new URL(`/api/auth${WORKSPACE_HANDOFF_EXCHANGE_PATH}`, origin); + url.searchParams.set(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM, input.ticket); + return url.toString(); +} + +function isPayloadShape(value: unknown): value is WorkspaceHandoffTicketPayload { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ( + typeof candidate.jti === "string" && candidate.jti.length > 0 + && typeof candidate.sub === "string" && candidate.sub.length > 0 + && typeof candidate.email === "string" && candidate.email.length > 0 + && typeof candidate.ws === "string" && candidate.ws.length > 0 + && typeof candidate.cid === "string" && candidate.cid.length > 0 + && typeof candidate.iid === "string" && candidate.iid.length > 0 + && typeof candidate.aud === "string" && candidate.aud.length > 0 + && typeof candidate.iss === "string" + && Number.isFinite(candidate.iat) + && Number.isFinite(candidate.exp) + && typeof candidate.next === "string" + ); +} + +/** + * Verify a ticket against this workspace's own key and identity. + * + * `expected` must come from persisted configuration. Passing header-derived + * values would reintroduce exactly the spoofing path this design closes. + */ +export function verifyWorkspaceHandoffTicket(input: { + ticket: string | null | undefined; + key: string | null | undefined; + expected: { + instanceId: string | null; + executionWorkspaceId: string | null; + companyId: string | null; + origin: string | null; + }; + now?: Date; + clockSkewSeconds?: number; +}): WorkspaceHandoffVerificationResult { + const key = input.key?.trim(); + if (!key) return { ok: false, reason: "not_configured" }; + + const expectedOrigin = normalizeWorkspaceHandoffOrigin(input.expected.origin); + const expectedInstanceId = input.expected.instanceId?.trim(); + const expectedWorkspaceId = input.expected.executionWorkspaceId?.trim(); + const expectedCompanyId = input.expected.companyId?.trim(); + // Fail closed on unresolved local identity: without it the checks below would + // degrade into "any validly signed ticket wins". + if (!expectedOrigin) return { ok: false, reason: "origin_mismatch" }; + if (!expectedInstanceId) return { ok: false, reason: "instance_mismatch" }; + if (!expectedWorkspaceId) return { ok: false, reason: "workspace_mismatch" }; + if (!expectedCompanyId) return { ok: false, reason: "company_mismatch" }; + + const ticket = input.ticket?.trim(); + if (!ticket) return { ok: false, reason: "malformed" }; + const segments = ticket.split("."); + if (segments.length !== 3) return { ok: false, reason: "malformed" }; + const [version, encodedPayload, encodedSignature] = segments as [string, string, string]; + if (version !== WORKSPACE_HANDOFF_TICKET_VERSION) return { ok: false, reason: "unsupported_version" }; + + const providedSignature = base64UrlDecode(encodedSignature); + if (!providedSignature) return { ok: false, reason: "malformed" }; + const expectedSignature = sign(key, `${version}.${encodedPayload}`); + // Signature first: never parse attacker-controlled JSON we have not authenticated. + if (!constantTimeMatches(expectedSignature, providedSignature)) return { ok: false, reason: "bad_signature" }; + + const decodedPayload = base64UrlDecode(encodedPayload); + if (!decodedPayload) return { ok: false, reason: "malformed" }; + let parsed: unknown; + try { + parsed = JSON.parse(decodedPayload.toString("utf8")); + } catch { + return { ok: false, reason: "malformed" }; + } + if (!isPayloadShape(parsed)) return { ok: false, reason: "malformed" }; + if (parsed.v !== WORKSPACE_HANDOFF_TICKET_VERSION) return { ok: false, reason: "unsupported_version" }; + + const nowSeconds = Math.floor((input.now ?? new Date()).getTime() / 1000); + const skew = Math.max(0, Math.floor(input.clockSkewSeconds ?? WORKSPACE_HANDOFF_CLOCK_SKEW_SECONDS)); + if (parsed.exp + skew < nowSeconds) return { ok: false, reason: "expired" }; + if (parsed.iat - skew > nowSeconds) return { ok: false, reason: "not_yet_valid" }; + if (parsed.exp <= parsed.iat) return { ok: false, reason: "malformed" }; + + if (normalizeWorkspaceHandoffOrigin(parsed.aud) !== expectedOrigin) { + return { ok: false, reason: "origin_mismatch" }; + } + if (parsed.iid !== expectedInstanceId) return { ok: false, reason: "instance_mismatch" }; + if (parsed.ws !== expectedWorkspaceId) return { ok: false, reason: "workspace_mismatch" }; + if (parsed.cid !== expectedCompanyId) return { ok: false, reason: "company_mismatch" }; + + return { + ok: true, + payload: { ...parsed, next: sanitizeWorkspaceHandoffRedirectPath(parsed.next) }, + }; +} + +/** Verification-key identifier safe to log: proves which key, reveals no key bytes. */ +export function workspaceHandoffKeyFingerprint(key: string): string { + return createHmac("sha256", "paperclip.workspace-login-handoff.fingerprint").update(key).digest("hex").slice(0, 12); +} + +/** + * Strip the ticket from a URL or URL-ish string before it reaches a log sink. + * Applied by the request logger and by the guest's own audit records. + */ +export function redactWorkspaceHandoffTicket(value: string): string { + if (!value.includes(WORKSPACE_HANDOFF_TICKET_QUERY_PARAM)) return value; + return value.replace( + new RegExp(`([?&]${WORKSPACE_HANDOFF_TICKET_QUERY_PARAM}=)[^&#\\s]+`, "gi"), + "$1[redacted]", + ); +} 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/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts index 800aa1e3158..b78e80fe70b 100644 --- a/server/src/dev-runner-worktree.ts +++ b/server/src/dev-runner-worktree.ts @@ -1,6 +1,7 @@ import { existsSync, lstatSync, readFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { hasVerifiedWorktreeSeedManifest } from "./worktree-seed-manifest.js"; function parseEnvFile(contents: string): Record { const entries: Record = {}; @@ -58,6 +59,10 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string { export function isWorktreeSeedPending(rootDir: string): boolean { const markerDir = path.resolve(rootDir, ".paperclip"); + const manifestPath = path.resolve(markerDir, "seed-manifest.json"); + if (existsSync(manifestPath)) { + return !hasVerifiedWorktreeSeedManifest(manifestPath); + } return existsSync(path.resolve(markerDir, "seed-pending")) && !existsSync(path.resolve(markerDir, "seed-complete")); } diff --git a/server/src/embedded-postgres-supervisor.ts b/server/src/embedded-postgres-supervisor.ts new file mode 100644 index 00000000000..399a248b0b8 --- /dev/null +++ b/server/src/embedded-postgres-supervisor.ts @@ -0,0 +1,92 @@ +export type EmbeddedPostgresExitListener = (code: number | null, signal: NodeJS.Signals | null) => void; + +export interface SupervisedEmbeddedPostgres { + start(): Promise; + stop(): Promise; + process?: { once(event: "exit", listener: EmbeddedPostgresExitListener): unknown }; +} + +export interface EmbeddedPostgresSupervisor { + current(): SupervisedEmbeddedPostgres; + shutdown(): Promise; + waitForRecovery(): Promise; +} + +type Options = { + initialInstance: SupervisedEmbeddedPostgres; + createInstance: () => SupervisedEmbeddedPostgres; + beforeRestart?: (attempt: number) => Promise | void; + restartDelaysMs?: number[]; + delay?: (milliseconds: number) => Promise; + onUnexpectedExit?: EmbeddedPostgresExitListener; + onRestartAttemptFailed?: (error: unknown, attempt: number) => void; + onRestarted?: (attempt: number) => void; + onRecoveryExhausted?: (error: unknown) => void; +}; + +const defaultDelay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export function createEmbeddedPostgresSupervisor(options: Options): EmbeddedPostgresSupervisor { + const restartDelaysMs = options.restartDelaysMs ?? [0, 250, 1_000]; + const wait = options.delay ?? defaultDelay; + let activeInstance = options.initialInstance; + let activeInstanceExited = false; + let shuttingDown = false; + let recoveryPromise: Promise | null = null; + + const recover = async () => { + let lastError: unknown = new Error("Embedded PostgreSQL exited unexpectedly"); + for (let index = 0; index < restartDelaysMs.length; index += 1) { + if (shuttingDown) return; + const attempt = index + 1; + const delayMs = restartDelaysMs[index] ?? 0; + if (delayMs > 0) await wait(delayMs); + if (shuttingDown) return; + try { + await options.beforeRestart?.(attempt); + const replacement = options.createInstance(); + await replacement.start(); + if (shuttingDown) { + await replacement.stop(); + return; + } + activeInstance = replacement; + activeInstanceExited = false; + monitor(replacement); + options.onRestarted?.(attempt); + return; + } catch (error) { + lastError = error; + options.onRestartAttemptFailed?.(error, attempt); + } + } + if (!shuttingDown) options.onRecoveryExhausted?.(lastError); + }; + + const monitor = (instance: SupervisedEmbeddedPostgres) => { + const child = instance.process; + if (!child) { + options.onRecoveryExhausted?.(new Error("Embedded PostgreSQL started without a child process to monitor")); + return; + } + child.once("exit", (code, signal) => { + if (activeInstance !== instance) return; + activeInstanceExited = true; + if (shuttingDown) return; + options.onUnexpectedExit?.(code, signal); + recoveryPromise = recover().finally(() => { recoveryPromise = null; }); + }); + }; + + monitor(activeInstance); + return { + current: () => activeInstance, + waitForRecovery: async () => { await recoveryPromise; }, + shutdown: async () => { + if (shuttingDown) return; + shuttingDown = true; + await recoveryPromise; + if (!activeInstanceExited) await activeInstance.stop(); + }, + }; +} diff --git a/server/src/index.ts b/server/src/index.ts index c399f1b8b80..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, @@ -96,6 +97,11 @@ import { } from "./shutdown.js"; import { systemdNotify } from "./services/systemd-notify.js"; import { flushInFlightRunLogMirrors } from "./services/run-log-store.js"; +import { + createEmbeddedPostgresSupervisor, + type EmbeddedPostgresSupervisor, + type SupervisedEmbeddedPostgres, +} from "./embedded-postgres-supervisor.js"; import type { InstanceDatabaseBackupRunResult, InstanceDatabaseBackupTrigger, @@ -112,10 +118,8 @@ type BetterAuthSessionResult = { user: BetterAuthSessionUser | null; }; -type EmbeddedPostgresInstance = { +type EmbeddedPostgresInstance = SupervisedEmbeddedPostgres & { initialise(): Promise; - start(): Promise; - stop(): Promise; }; type EmbeddedPostgresCtor = new (opts: { @@ -326,6 +330,7 @@ export async function startServer(): Promise { let db; let pluginMigrationDb; let embeddedPostgres: EmbeddedPostgresInstance | null = null; + let embeddedPostgresSupervisor: EmbeddedPostgresSupervisor | null = null; let embeddedPostgresStartedByThisProcess = false; let migrationSummary: MigrationSummary = "skipped"; let activeDatabaseConnectionString: string; @@ -450,7 +455,7 @@ export async function startServer(): Promise { } port = detectedPort; logger.info(`Using embedded PostgreSQL because no DATABASE_URL set (dataDir=${dataDir}, port=${port})`); - embeddedPostgres = new EmbeddedPostgres({ + const createEmbeddedPostgres = () => new EmbeddedPostgres({ databaseDir: dataDir, user: "paperclip", password: "paperclip", @@ -460,6 +465,7 @@ export async function startServer(): Promise { onLog: appendEmbeddedPostgresLog, onError: appendEmbeddedPostgresLog, }); + embeddedPostgres = createEmbeddedPostgres(); if (!clusterAlreadyInitialized) { try { @@ -489,6 +495,36 @@ export async function startServer(): Promise { }); } embeddedPostgresStartedByThisProcess = true; + embeddedPostgresSupervisor = createEmbeddedPostgresSupervisor({ + initialInstance: embeddedPostgres, + createInstance: createEmbeddedPostgres, + beforeRestart: () => { + const runningPostgresPid = getRunningPid(); + if (runningPostgresPid) { + throw new Error(`Refusing embedded PostgreSQL recovery because the data directory reports a live process (pid=${runningPostgresPid})`); + } + if (existsSync(postmasterPidFile)) rmSync(postmasterPidFile, { force: true }); + }, + onUnexpectedExit: (code, signal) => logger.error( + { code, signal, recentLogs: logBuffer.getRecentLogs() }, + "Embedded PostgreSQL exited unexpectedly; attempting recovery", + ), + onRestartAttemptFailed: (err, attempt) => logger.error( + { err, attempt, recentLogs: logBuffer.getRecentLogs() }, + "Embedded PostgreSQL recovery attempt failed", + ), + onRestarted: (attempt) => logger.info( + { attempt, port }, + "Embedded PostgreSQL recovered after unexpected exit", + ), + onRecoveryExhausted: (err) => { + logger.fatal( + { err, recentLogs: logBuffer.getRecentLogs() }, + "Embedded PostgreSQL recovery exhausted; stopping the unhealthy server", + ); + process.kill(process.pid, "SIGTERM"); + }, + }); } } @@ -1049,7 +1085,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 +1119,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; @@ -1128,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, @@ -1265,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); @@ -1328,6 +1402,7 @@ export async function startServer(): Promise { scheduleMergedPullRequestConfirmationSweep(); scheduleTerminalWorkspaceSweep(); scheduleAdapterLoginReaperSweep(); + scheduleSetupTokenReaperSweep(); scheduleEnvironmentLeaseCleanupSweep(); if (heartbeatSchedulerStopped) return; @@ -1632,8 +1707,9 @@ export async function startServer(): Promise { const appShutdown = (app as { locals?: { paperclipShutdown?: () => Promise } }).locals ?.paperclipShutdown; - const embeddedPostgresToStop = - embeddedPostgres && embeddedPostgresStartedByThisProcess ? embeddedPostgres : null; + const stopEmbeddedPostgres = embeddedPostgres && embeddedPostgresStartedByThisProcess + ? () => embeddedPostgresSupervisor?.shutdown() ?? embeddedPostgres!.stop() + : null; // Await the ordered application teardown before the process exits. A live // setup-token login session must stop and release its sandbox lease before @@ -1642,7 +1718,7 @@ export async function startServer(): Promise { await finalizeServerShutdown({ signal, shutdownAppServices: appShutdown, - stopEmbeddedPostgres: embeddedPostgresToStop ? () => embeddedPostgresToStop.stop() : null, + stopEmbeddedPostgres, shutdownInstrumentation, log: logger, }); diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 5ba9ad5821c..0a4aa1a1703 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -90,6 +90,7 @@ export function errorHandler( ? err.details as Record : null; const redactedSkillPolicyDenial = isRedactedSkillPolicyDenial(details); + const workspaceRepairPreconditionFailure = details?.code === "workspace_repair_precondition_failed"; const structuredConnectionError = new Set([ "user_authorization_required", "grant_revoked", @@ -113,13 +114,19 @@ export function errorHandler( error: err.message, ...(typeof details?.code === "string" ? { code: details.code } : {}), ...(redactedSkillPolicyDenial && typeof details?.reason === "string" ? { reason: details.reason } : {}), + ...(workspaceRepairPreconditionFailure && typeof details?.reason === "string" ? { reason: details.reason } : {}), + ...(workspaceRepairPreconditionFailure && typeof details?.repairPhase === "string" + ? { repairPhase: details.repairPhase } + : {}), ...(typeof details?.remediation === "string" || (structuredConnectionError && details?.remediation && typeof details.remediation === "object") ? { remediation: details.remediation } : {}), ...(structuredConnectionError && details?.connection ? { connection: details.connection } : {}), ...(structuredConnectionError && details?.subject ? { subject: details.subject } : {}), ...(structuredConnectionError && typeof details?.grantId === "string" ? { grantId: details.grantId } : {}), - ...(!redactedSkillPolicyDenial && err.details ? { details: err.details } : {}), + ...(!redactedSkillPolicyDenial && !workspaceRepairPreconditionFailure && err.details + ? { details: err.details } + : {}), }); return; } diff --git a/server/src/middleware/logger.ts b/server/src/middleware/logger.ts index d46f9adff99..dcc4320708e 100644 --- a/server/src/middleware/logger.ts +++ b/server/src/middleware/logger.ts @@ -3,6 +3,7 @@ import { pinoHttp } from "pino-http"; import { HTTP_LOG_REDACT_PATHS } from "./http-log-redaction.js"; import { shouldSilenceHttpSuccessLog } from "./http-log-policy.js"; import { redactSensitive } from "./redact-sensitive.js"; +import { redactWorkspaceHandoffTicket } from "../auth/workspace-login-handoff.js"; const sharedOpts = { translateTime: "SYS:HH:MM:ss", @@ -29,12 +30,14 @@ export const httpLogger = pinoHttp({ return "info"; }, customSuccessMessage(req, res) { - return `${req.method} ${req.url} ${res.statusCode}`; + // A workspace login handoff ticket is a bearer credential that rides in the + // query string, so the request line has to be redacted before it is logged. + return `${req.method} ${redactWorkspaceHandoffTicket(req.url ?? "")} ${res.statusCode}`; }, customErrorMessage(req, res, err) { const ctx = (res as any).__errorContext; const errMsg = ctx?.error?.message || err?.message || (res as any).err?.message || "unknown error"; - return `${req.method} ${req.url} ${res.statusCode} — ${errMsg}`; + return `${req.method} ${redactWorkspaceHandoffTicket(req.url ?? "")} ${res.statusCode} — ${errMsg}`; }, customProps(req, res) { if (res.statusCode >= 400) { diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts index 3197e459f38..b7e7b873155 100644 --- a/server/src/middleware/redact-sensitive.ts +++ b/server/src/middleware/redact-sensitive.ts @@ -45,6 +45,10 @@ const SENSITIVE_KEYS = new Set([ "browsercode", "authorization_code", "authorizationcode", + // The workspace login handoff ticket (PAP-17572). It is a signed bearer + // credential carried as a query parameter, so it must never reach a log line + // even though the exchange itself answers 302. + "ticket", ]); const MAX_DEPTH = 6; 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 834f1e2407a..053d11f1154 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, @@ -741,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, @@ -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/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index 9477b2d0ac5..442ab7b7847 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -1,3 +1,6 @@ +import { spawn } from "node:child_process"; +import { accessSync, constants as fsConstants, existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import { and, eq } from "drizzle-orm"; import { Router, type Request, type Response } from "express"; import type { Db } from "@paperclipai/db"; @@ -11,6 +14,10 @@ import { workspaceRuntimeControlTargetSchema, } from "@paperclipai/shared"; import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared"; +import { + resolveCanonicalWorktreeSeedSource, + type CanonicalWorktreeSeedSource, +} from "@paperclipai/shared/worktree-seed-source"; import { validate } from "../middleware/validate.js"; import { accessService, @@ -45,9 +52,25 @@ import { appendWithCap } from "../adapters/utils.js"; import { environmentRuntimeService } from "../services/environment-runtime.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import { runExclusiveWorkspaceRuntimeControl } from "../services/workspace-operations.js"; +import { resolveManagedWorkspaceInstanceId } from "../services/managed-workspace-identity.js"; +import { isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js"; +import { + issueWorkspaceLoginHandoff, + workspaceLoginHandoffFailureStatus, +} from "../services/workspace-login-handoff-issuer.js"; +import { conflict, unprocessable } from "../errors.js"; const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024; +function isReadableFile(filePath: string) { + try { + accessSync(filePath, fsConstants.R_OK); + return true; + } catch { + return false; + } +} + export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: PluginWorkerManager } = {}) { const router = Router(); const svc = executionWorkspaceService(db); @@ -139,6 +162,87 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P res.json(readiness); }); + /** + * Mint a single-use workspace login handoff for the calling board user. + * + * Board-only: the ticket carries a user identity, so an agent key — which has + * no user to sign in — must never be able to mint one. Nothing in the request + * body influences what the ticket is bound to; only the landing path is taken + * from the caller, and it is reduced to a same-origin path before signing. + */ + router.post("/execution-workspaces/:id/login-handoff", async (req, res) => { + const id = req.params.id as string; + assertBoard(req); + const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); + if (!workspace) return; + // Opening a workspace board is a runtime-control-grade action: it hands the + // caller an authenticated session inside the cloned instance. + if (!(await assertRuntimeManageAllowed(req, res, workspace.companyId))) return; + + const requestedNext = typeof (req.body as { next?: unknown } | null)?.next === "string" + ? (req.body as { next: string }).next + : null; + const result = await issueWorkspaceLoginHandoff({ + db, + companyId: workspace.companyId, + executionWorkspace: { id: workspace.id, cwd: workspace.cwd }, + actor: { + userId: req.actor.userId ?? null, + userEmail: req.actor.userEmail ?? null, + source: req.actor.source ?? null, + }, + next: requestedNext, + }); + + if (!result.ok) { + logger.warn( + { + executionWorkspaceId: workspace.id, + reason: result.failure.reason, + detail: "detail" in result.failure ? result.failure.detail : null, + }, + "workspace login handoff was not issued", + ); + res.status(workspaceLoginHandoffFailureStatus(result.failure)).json({ + error: "workspace_login_handoff_unavailable", + // The UI turns this into the labeled snapshot-local credential fallback + // or a repair prompt, so the machine reason has to survive the boundary. + reason: result.failure.reason, + mode: "credentials", + ...("detail" in result.failure ? { detail: result.failure.detail } : {}), + ...("readiness" in result.failure ? { readiness: result.failure.readiness } : {}), + }); + return; + } + + await logActivity(db, { + companyId: workspace.companyId, + // Auditing issuance is a requirement of the handoff design: the nonce ties + // this record to the guest's own accept/reject record. The ticket itself is + // never recorded. + action: "workspace_login_handoff_issued", + entityType: "execution_workspace", + entityId: workspace.id, + actorType: "user", + actorId: result.issuance.userId, + details: { + nonce: result.issuance.nonce, + instanceId: result.issuance.instanceId, + origin: result.issuance.origin, + expiresAt: result.issuance.expiresAt, + }, + }).catch((error) => { + logger.warn({ err: error, executionWorkspaceId: workspace.id }, "failed to audit workspace login handoff"); + }); + + res.setHeader("Cache-Control", "no-store"); + res.status(201).json({ + url: result.issuance.url, + expiresAt: result.issuance.expiresAt, + mode: "handoff", + }); + }); + router.get("/execution-workspaces/:id/workspace-operations", async (req, res) => { const id = req.params.id as string; const workspace = await getAccessibleResource(req, res, svc.getById(id), "Execution workspace not found"); @@ -151,7 +255,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P async function handleExecutionWorkspaceRuntimeCommand(req: Request, res: Response) { const id = req.params.id as string; const action = String(req.params.action ?? "").trim().toLowerCase(); - if (action !== "start" && action !== "stop" && action !== "restart" && action !== "run") { + if (action !== "start" && action !== "stop" && action !== "restart" && action !== "repair" && action !== "run") { res.status(404).json({ error: "Workspace command action not found" }); return; } @@ -166,14 +270,6 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P sourceIssueId: existing.sourceIssueId, }); - // Recover any managed runtime-control operation this workspace was stranded with, then - // refuse only if one is genuinely still live. Authorization above still gates the caller, - // so recovery never widens who may control the workspace. - await workspaceOperationsSvc.assertRuntimeControlAvailable({ - executionWorkspaceId: existing.id, - action, - }); - const workspaceCwd = existing.cwd; if (!workspaceCwd) { res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can run workspace commands" }); @@ -195,6 +291,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P and( eq(projectWorkspaces.id, existing.projectWorkspaceId), eq(projectWorkspaces.companyId, existing.companyId), + eq(projectWorkspaces.projectId, existing.projectId), ), ) .then((rows) => rows[0] ?? null) @@ -221,6 +318,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P const configuredServices = effectiveRuntimeConfig ? listConfiguredRuntimeServiceEntries({ workspaceRuntime: effectiveRuntimeConfig }) : []; + const repairRestartsRuntimeServices = action === "repair" && configuredServices.length > 0; const workspaceCommand = effectiveRuntimeConfig ? findWorkspaceCommandDefinition(effectiveRuntimeConfig, target.workspaceCommandId ?? null) : null; @@ -267,6 +365,78 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P return; } + let repairSeedSource: CanonicalWorktreeSeedSource | null = null; + let repairPreviousAttemptId: string | null = null; + let repairCliArgs: string[] | null = null; + if (action === "repair") { + const manifestPath = path.join(workspaceCwd, ".paperclip", "seed-manifest.json"); + let manifest: { + attemptId?: unknown; + source?: { configPath?: unknown; instanceId?: unknown }; + targetInstanceId?: unknown; + }; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + } catch { + throw unprocessable("Workspace seed manifest is malformed; repair source identity cannot be trusted.", { + code: "workspace_repair_precondition_failed", + reason: "seed_manifest_malformed", + repairPhase: "precondition_validation", + }); + } + + try { + if (!projectWorkspace?.cwd) { + throw new Error("Workspace repair requires a registered base project workspace."); + } + const expectedTargetInstanceId = resolveManagedWorkspaceInstanceId(workspaceCwd); + if (!expectedTargetInstanceId) { + throw new Error("Workspace repair cannot resolve the registered target instance."); + } + repairSeedSource = resolveCanonicalWorktreeSeedSource({ + registeredBaseWorkspaceCwd: projectWorkspace.cwd, + targetConfigPath: path.join(workspaceCwd, ".paperclip", "config.json"), + expectedTargetInstanceId, + manifestSource: manifest.source, + manifestTargetInstanceId: manifest.targetInstanceId, + }); + repairPreviousAttemptId = typeof manifest.attemptId === "string" ? manifest.attemptId : null; + + const baseWorkspaceCwd = repairSeedSource.baseWorkspaceCwd; + if (!baseWorkspaceCwd) { + throw new Error("Workspace repair source is not bound to a registered base project workspace."); + } + const cliRunner = path.join(baseWorkspaceCwd, "cli", "node_modules", "tsx", "dist", "cli.mjs"); + const cliEntry = path.join(baseWorkspaceCwd, "cli", "src", "index.ts"); + const cliDist = path.join(baseWorkspaceCwd, "cli", "dist", "index.js"); + repairCliArgs = isReadableFile(cliRunner) && isReadableFile(cliEntry) + ? [cliRunner, cliEntry] + : isReadableFile(cliDist) + ? [cliDist] + : null; + if (!repairCliArgs) { + throw new Error("Workspace repair cannot find a runnable Paperclip CLI in the base workspace."); + } + } catch (error) { + throw unprocessable( + error instanceof Error ? error.message : "Workspace repair source validation failed.", + { + code: "workspace_repair_precondition_failed", + reason: "source_registration_invalid", + repairPhase: "precondition_validation", + }, + ); + } + } + + // This check can reconcile a stale operation row, so repair source validation must + // precede it. Invalid manifest diagnostics must fail before any operation or service + // mutation, not merely before the reseed child process is spawned. + await workspaceOperationsSvc.assertRuntimeControlAvailable({ + executionWorkspaceId: existing.id, + action, + }); + const actor = getActorInfo(req); const recorder = workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, @@ -327,7 +497,11 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P } const recordRuntimeControlOperation = () => recorder.recordOperation({ - phase: action === "stop" ? "workspace_teardown" : "workspace_provision", + phase: action === "stop" + ? "workspace_teardown" + : action === "repair" + ? "workspace_repair" + : "workspace_provision", command: workspaceCommand?.command ?? `workspace command ${action}`, cwd: existing.cwd, metadata: { @@ -339,7 +513,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, }, - run: async () => { + run: async (reportProgress) => { const ensureWorkspaceAvailable = async () => await ensurePersistedExecutionWorkspaceAvailable({ base: { @@ -429,7 +603,242 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P else stderr = appendWithCap(stderr, chunk, WORKSPACE_CONTROL_OUTPUT_MAX_CHARS); }; - if (action === "stop" || action === "restart") { + if (action === "repair") { + type RepairPhase = + | "managed_stop" + | "precondition_validation" + | "target_backup" + | "full_reseed" + | "managed_restart" + | "readiness_validation"; + const repairDiagnostics: Array<{ + phase: RepairPhase; + status: "started" | "succeeded" | "failed"; + at: string; + }> = []; + let repairPhase: RepairPhase = "managed_stop"; + const repairPreconditionError = ( + status: 409 | 422, + reason: + | "seed_manifest_malformed" + | "seed_manifest_instance_mismatch" + | "source_instance_unavailable" + | "paperclip_cli_unavailable", + message: string, + ) => { + const details = { + code: "workspace_repair_precondition_failed", + reason, + repairPhase, + }; + return status === 409 + ? conflict(message, details) + : unprocessable(message, details); + }; + const reportRepairPhase = async ( + phase: RepairPhase, + status: "started" | "succeeded" | "failed", + ) => { + repairPhase = phase; + repairDiagnostics.push({ phase, status, at: new Date().toISOString() }); + if (repairDiagnostics.length > 32) repairDiagnostics.splice(0, repairDiagnostics.length - 32); + await reportProgress({ + metadata: { + repairPhase, + repairDiagnostics: [...repairDiagnostics], + databaseOnly: true, + worktreePreserved: true, + }, + system: `Workspace repair ${phase}: ${status}.\n`, + }); + }; + + try { + await reportRepairPhase("managed_stop", "started"); + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId: existing.id, + workspaceCwd, + }); + await reportRepairPhase("managed_stop", "succeeded"); + if (!repairSeedSource?.baseWorkspaceCwd || !repairCliArgs) { + throw new Error("Workspace repair source preflight did not complete."); + } + const manifestPath = path.join(workspaceCwd, ".paperclip", "seed-manifest.json"); + const sourceConfigPath = repairSeedSource.configPath; + const baseWorkspaceCwd = repairSeedSource.baseWorkspaceCwd; + + await reportRepairPhase("target_backup", "started"); + const child = spawn(process.execPath, [ + ...repairCliArgs, + "worktree", + "reseed", + "--from-config", + sourceConfigPath, + "--to", + workspaceCwd, + "--seed-mode", + "full", + "--yes", + "--backup-target", + ], { + cwd: baseWorkspaceCwd, + env: { + ...process.env, + PAPERCLIP_SEED_EXPECTED_COMPANY_ID: existing.companyId, + PAPERCLIP_WORKSPACE_BASE_CWD: baseWorkspaceCwd, + PAPERCLIP_PROJECT_WORKSPACE_ID: existing.projectWorkspaceId ?? "", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout?.on("data", (chunk: Buffer) => { + void onLog("stdout", chunk.toString("utf8")); + }); + child.stderr?.on("data", (chunk: Buffer) => { + void onLog("stderr", chunk.toString("utf8")); + }); + let repairCommandTimedOut = false; + let repairCommandForceTimeout: NodeJS.Timeout | null = null; + const repairCommandTimeout = setTimeout(() => { + repairCommandTimedOut = true; + child.kill("SIGTERM"); + repairCommandForceTimeout = setTimeout(() => child.kill("SIGKILL"), 5_000); + repairCommandForceTimeout.unref?.(); + }, 20 * 60_000); + repairCommandTimeout.unref?.(); + + let reseedObserved = false; + const manifestPoll = setInterval(() => { + if (reseedObserved || !existsSync(manifestPath)) return; + try { + const current = JSON.parse(readFileSync(manifestPath, "utf8")) as { + attemptId?: unknown; + state?: unknown; + }; + if ( + typeof current.attemptId === "string" + && current.attemptId !== repairPreviousAttemptId + && (current.state === "pending" || current.state === "running" || current.state === "failed") + ) { + reseedObserved = true; + } + } catch { + // The atomic manifest writer makes this unlikely; the terminal + // read below remains authoritative. + } + }, 100); + manifestPoll.unref?.(); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => resolve(code ?? 1)); + }).finally(() => { + clearInterval(manifestPoll); + clearTimeout(repairCommandTimeout); + if (repairCommandForceTimeout) clearTimeout(repairCommandForceTimeout); + }); + if (reseedObserved) { + await reportRepairPhase("target_backup", "succeeded"); + await reportRepairPhase("full_reseed", "started"); + } + if (exitCode !== 0) { + let seedFailurePhase: string | null = null; + try { + const failedManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + phase?: unknown; + state?: unknown; + }; + seedFailurePhase = failedManifest.state === "failed" && typeof failedManifest.phase === "string" + ? failedManifest.phase + : null; + } catch { + // The outer repair phase remains exact when no seed phase was persisted. + } + await reportProgress({ + metadata: { seedFailurePhase }, + system: seedFailurePhase ? `Workspace seed failed during ${seedFailurePhase}.\n` : null, + }); + throw new Error( + repairCommandTimedOut + ? `Workspace database repair command timed out during ${repairPhase}.` + : `Workspace database repair command failed during ${repairPhase}.`, + ); + } + if (!reseedObserved) { + await reportRepairPhase("target_backup", "succeeded"); + await reportRepairPhase("full_reseed", "started"); + } + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + if (!isVerifiedWorktreeSeedManifest(manifest)) { + throw new Error("Workspace reseed returned without a verified terminal manifest."); + } + const expectedInstanceId = resolveManagedWorkspaceInstanceId(workspaceCwd); + if (manifest.targetInstanceId !== expectedInstanceId) { + throw repairPreconditionError( + 422, + "seed_manifest_instance_mismatch", + "Verified seed manifest belongs to a different workspace instance.", + ); + } + resolveCanonicalWorktreeSeedSource({ + registeredBaseWorkspaceCwd: baseWorkspaceCwd, + targetConfigPath: path.join(workspaceCwd, ".paperclip", "config.json"), + expectedTargetInstanceId: repairSeedSource.targetInstanceId, + manifestSource: manifest.source as { configPath?: unknown; instanceId?: unknown } | undefined, + manifestTargetInstanceId: manifest.targetInstanceId, + }); + await reportRepairPhase("full_reseed", "succeeded"); + + await reportRepairPhase("managed_restart", "started"); + let startedServices: Awaited> = []; + if (repairRestartsRuntimeServices) { + const availableWorkspace = await ensureWorkspaceAvailable(); + if (!availableWorkspace) { + throw new Error("Execution workspace needs a local path before Paperclip can restart it."); + } + startedServices = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: existing.companyId, + }, + issue: existing.sourceIssueId + ? { id: existing.sourceIssueId, identifier: null, title: existing.name } + : null, + workspace: availableWorkspace, + executionWorkspaceId: existing.id, + config: { + workspaceRuntime: effectiveRuntimeConfig, + runtimeProvisionCommand: + existing.config?.runtimeProvisionCommand + ?? projectPolicy?.workspaceStrategy?.runtimeProvisionCommand + ?? null, + }, + adapterEnv: {}, + onLog, + recorder, + }); + } + runtimeServiceCount = startedServices.length; + await reportRepairPhase("managed_restart", "succeeded"); + + await reportRepairPhase("readiness_validation", "started"); + if ( + repairRestartsRuntimeServices + && ( + startedServices.length === 0 + || startedServices.some((service) => service.status !== "running" || service.healthStatus !== "healthy") + ) + ) { + throw new Error("Managed restart did not produce healthy runtime services."); + } + await reportRepairPhase("readiness_validation", "succeeded"); + } catch (error) { + await reportRepairPhase(repairPhase, "failed"); + throw error; + } + } else if (action === "stop" || action === "restart") { await stopRuntimeServicesForExecutionWorkspace({ db, executionWorkspaceId: existing.id, @@ -481,7 +890,7 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P throw error; } runtimeServiceCount = startedServices.length; - } else { + } else if (action !== "repair") { runtimeServiceCount = selectedRuntimeServiceId ? Math.max(0, (existing.runtimeServices?.length ?? 1) - 1) : 0; } @@ -504,7 +913,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P config: { workspaceRuntime: effectiveRuntimeConfig }, currentDesiredState, currentServiceStates: existing.config?.serviceStates ?? null, - action, + action: action === "repair" + ? repairRestartsRuntimeServices ? "start" : "stop" + : action, serviceIndex: selectedServiceIndex, }); const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record | null, { @@ -522,12 +933,17 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P ? "Stopped execution workspace runtime services.\n" : action === "restart" ? "Restarted execution workspace runtime services.\n" - : "Started execution workspace runtime services.\n", + : action === "repair" + ? repairRestartsRuntimeServices + ? "Repaired the isolated workspace database and restarted healthy runtime services.\n" + : "Repaired the isolated workspace database; no managed runtime services were configured to restart.\n" + : "Started execution workspace runtime services.\n", metadata: { runtimeServiceCount, workspaceCommandId: workspaceCommand?.id ?? target.workspaceCommandId ?? null, runtimeServiceId: selectedRuntimeServiceId, serviceIndex: selectedServiceIndex, + runtimeRestarted: action === "repair" ? repairRestartsRuntimeServices : undefined, }, }; }, @@ -565,7 +981,9 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P // The operation is already terminal here. This also catches the recorder's own time // budget expiring on a start that never settles, which is the one failure the inner // handler cannot see — reconcile there too so no listener or desired state is left over. - if (action === "start" || action === "restart") await reconcileFailedRuntimeStart(error); + if (action === "start" || action === "restart" || action === "repair") { + await reconcileFailedRuntimeStart(error); + } throw error; } }, diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index f81f9098a88..e46952ecc89 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -19,6 +19,13 @@ import { type InspectDatabaseBackupHealthOptions, } from "../services/database-backup-health.js"; import { instanceSettingsService } from "../services/instance-settings.js"; +import { isManagedWorkspaceInstance, resolveWorkspaceReadiness } from "../services/workspace-readiness.js"; +import { + resolveWorkspaceReadinessLocalToken, + WORKSPACE_READINESS_TOKEN_HEADER, + WORKSPACE_READINESS_USER_EMAIL_HEADER, + WORKSPACE_READINESS_USER_ID_HEADER, +} from "../auth/workspace-login-handoff.js"; import { serverVersion } from "../version.js"; function shouldExposeFullHealthDetails( @@ -29,17 +36,33 @@ function shouldExposeFullHealthDetails( return actorType === "board" || actorType === "agent"; } -function hasDevServerStatusToken(providedToken: string | undefined) { - const expectedToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN?.trim(); +function matchesSharedToken(expectedToken: string | undefined | null, providedToken: string | undefined) { + const expectedValue = expectedToken?.trim(); const token = providedToken?.trim(); - if (!expectedToken || !token) return false; + if (!expectedValue || !token) return false; - const expected = Buffer.from(expectedToken); + const expected = Buffer.from(expectedValue); const provided = Buffer.from(token); if (expected.length !== provided.length) return false; return timingSafeEqual(expected, provided); } +function hasDevServerStatusToken(providedToken: string | undefined) { + return matchesSharedToken(process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN, providedToken); +} + +/** + * Whether the caller may read this instance's workspace readiness. + * + * A managed workspace runs in `authenticated` mode, so its own control plane has + * no board session against it. The runtime injects a derived probe token into the + * guest and presents it here — the same shared-secret shape the dev-server + * supervisor already uses, and never a browser-supplied identity header. + */ +function hasWorkspaceReadinessToken(providedToken: string | undefined) { + return matchesSharedToken(resolveWorkspaceReadinessLocalToken(), providedToken); +} + function redactedDatabaseBackupWarning(warning: DatabaseBackupHealthWarning): DatabaseBackupHealthWarning { const messages: Record = { database_backup_check_failed: "Database backup health check failed.", @@ -147,6 +170,17 @@ export function healthRoutes( const commit = serverInfo.git.available ? serverInfo.git.fullSha : null; const exposeDevServerDetails = exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token")); + // Workspace readiness names the instance and execution workspace that + // answered, so it rides the protected responses only. Public health stays + // redacted: an anonymous caller still learns liveness and nothing else. + const exposeWorkspaceReadiness = + isManagedWorkspaceInstance() + && (exposeFullDetails || hasWorkspaceReadinessToken(req.get(WORKSPACE_READINESS_TOKEN_HEADER))); + const requestedHandoffUserId = req.get(WORKSPACE_READINESS_USER_ID_HEADER)?.trim(); + const requestedHandoffUserEmail = req.get(WORKSPACE_READINESS_USER_EMAIL_HEADER)?.trim(); + const handoffSubject = requestedHandoffUserId && requestedHandoffUserEmail + ? { userId: requestedHandoffUserId, email: requestedHandoffUserEmail } + : null; if (!db) { res.json( @@ -173,6 +207,12 @@ export function healthRoutes( await db.execute(sql`SELECT 1`); } catch (error) { logger.warn({ err: error }, "Health check database probe failed"); + // Carry readiness on the unhealthy response too: the seed phase recorded on + // disk is exactly what tells an operator whether this is a half-finished + // restore or a database that died after being verified. + const workspace = exposeWorkspaceReadiness + ? await resolveWorkspaceReadiness({ db, handoffSubject }).catch(() => null) + : null; res.status(503).json({ status: "unhealthy", version: serverVersion, @@ -180,6 +220,7 @@ export function healthRoutes( commit, error: "database_unreachable", ...(exposeFullDetails ? { serverInfo } : {}), + ...(workspace ? { workspace } : {}), ...(cloud ? { cloud } : {}), }); return; @@ -236,6 +277,13 @@ export function healthRoutes( }); } + const workspaceReadiness = exposeWorkspaceReadiness + ? await resolveWorkspaceReadiness({ db, handoffSubject }).catch((error) => { + logger.warn({ err: error }, "workspace readiness probe failed"); + return null; + }) + : null; + const databaseBackup = opts.databaseBackupHealth ? inspectDatabaseBackupHealth(opts.databaseBackupHealth) : undefined; @@ -254,6 +302,10 @@ export function healthRoutes( ...(redactedDatabaseBackup ? { databaseBackup: redactedDatabaseBackup } : {}), ...(redactedWarnings ? { warnings: redactedWarnings } : {}), ...(devServer ? { devServer } : {}), + // Token-authorized probe on an otherwise redacted response: the control + // plane needs readiness without a board session, and nothing else about + // this instance becomes visible. + ...(workspaceReadiness ? { workspace: workspaceReadiness } : {}), ...(cloud ? { cloud } : {}), }); return; @@ -276,6 +328,7 @@ export function healthRoutes( ...(databaseBackup ? { databaseBackup } : {}), ...(warnings ? { warnings } : {}), ...(devServer ? { devServer } : {}), + ...(workspaceReadiness ? { workspace: workspaceReadiness } : {}), ...(cloud ? { cloud } : {}), }); }); 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/routes/openapi.ts b/server/src/routes/openapi.ts index 9e47791a5fc..01cb4f05b0a 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(), @@ -804,6 +810,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "PATCH /api/companies/{companyId}/members/{memberId}/permissions", "GET /api/companies/{companyId}/user-directory", "POST /api/execution-workspaces/{id}/reconcile-branch", + "POST /api/execution-workspaces/{id}/login-handoff", "GET /api/board-api-keys", "POST /api/board-api-keys", "DELETE /api/board-api-keys/{keyId}", @@ -5323,6 +5330,36 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, }); +registry.registerPath({ + method: "post", + path: "/api/execution-workspaces/{id}/login-handoff", + tags: ["execution-workspaces"], + summary: "Issue a single-use workspace login handoff", + description: + "Mints a short-lived, single-use ticket the isolated workspace exchanges for its own " + + "instance-scoped session, so opening a managed workspace does not depend on a cloned " + + "password. Board actors only. The response `url` must be navigated to, not stored: the " + + "workspace answers it with a redirect so the ticket never enters browser history. A refusal " + + "carries a machine `reason` and, where the control plane probed it, the workspace's own " + + "readiness.", + request: { + params: z.object({ id: z.string() }), + body: jsonBody( + z.object({ + next: z.string().optional().describe("Same-origin path to land on; anything else collapses to `/`."), + }), + ), + }, + responses: { + 201: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + 501: r.serverError, + }, +}); + registry.registerPath({ method: "post", path: "/api/execution-workspaces/{id}/runtime-services/{action}", @@ -5585,6 +5622,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/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/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/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 2689aaa2a8b..fccf326a4c0 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. @@ -499,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-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, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index 117e071f473..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 // --------------------------------------------------------------------------- @@ -89,6 +103,8 @@ export const SANDBOX_CAPABILITY_KEYS = [ "persistentProcessSessions", "independentControlCommands", "incrementalSessionOutput", + "concurrentSyncOperations", + "duplexCommandStream", ] as const; export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; @@ -104,6 +120,8 @@ export type SandboxCapabilityKey = (typeof SANDBOX_CAPABILITY_KEYS)[number]; */ const SANDBOX_CAPABILITY_OPT_IN_KEYS: ReadonlySet = new Set([ "incrementalSessionOutput", + "concurrentSyncOperations", + "duplexCommandStream", ]); /** @@ -134,6 +152,17 @@ 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}). + * - `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 @@ -147,6 +176,8 @@ const SANDBOX_CAPABILITY_PREREQUISITE_METHODS: Record; @@ -476,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; /** @@ -983,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: { @@ -2359,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 = @@ -3310,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/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 7208bf0827c..54b0a562f16 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, }; @@ -2173,6 +2218,13 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); if (!row) return null; + const { refreshPersistedRuntimeServiceHealth } = await import("./workspace-runtime.js"); + await refreshPersistedRuntimeServiceHealth({ + db, + companyId: row.companyId, + executionWorkspaceId: row.id, + projectWorkspaceId: row.projectWorkspaceId, + }); const runtimeServicesByWorkspaceId = await loadEffectiveRuntimeServicesByExecutionWorkspace(db, row.companyId, [row]); return hydrateWorkspace( row, @@ -2460,6 +2512,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic skippedUndelivered: 0, skippedRace: 0, skippedReopened: 0, + skippedCooldown: 0, clearedStaleReopenPending: 0, }; } @@ -2523,6 +2576,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic skippedUndelivered: 0, skippedRace: 0, skippedReopened: 0, + skippedCooldown: 0, clearedStaleReopenPending: 0, }; @@ -2564,6 +2618,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 +2760,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); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 768bcf2f699..62e3327651c 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2891,6 +2891,12 @@ export type ReferencedProjectFailureReason = "authorization" | "resolution" | "s export interface ReferencedProjectFailure { projectId: string; reason: ReferencedProjectFailureReason; + /** + * The failure message, when the layer that dropped the project produced one. A `staging` failure + * carries the remote extract or sync error here, so a reader of the run log learns why the project + * dropped. An `authorization` or `resolution` drop omits this field. + */ + error?: string; } export interface ResolvedRunReferencedProjects { @@ -3235,6 +3241,8 @@ export interface ReferencedProjectRunObservability { referenced_project_failures: Array<{ project_id: string; reason: ReferencedProjectFailureReason; + /** The failure message for a `staging` drop; absent for an `authorization` or `resolution` drop. */ + error?: string; }>; } @@ -3257,6 +3265,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 } : {}), })), }; } @@ -16301,6 +16312,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/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/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/managed-workspace-identity.ts b/server/src/services/managed-workspace-identity.ts new file mode 100644 index 00000000000..ba7a67be8f6 --- /dev/null +++ b/server/src/services/managed-workspace-identity.ts @@ -0,0 +1,386 @@ +/** + * Control-plane half of the workspace readiness/handoff contract (PAP-17572). + * + * Resolves the identity and derived key material for one managed workspace, + * injects them into the guest process, and probes the guest's *protected* + * readiness so `running / healthy` can never be published for a workspace whose + * clone, identity, or login handoff disagrees with what was expected. + * + * Identity always comes from the worktree's own recorded instance pointer, never + * from a branch name or basename heuristic, and an unresolvable identity fails + * closed (no key injected, no readiness claimed). + */ + +import path from "node:path"; +import { and, eq } from "drizzle-orm"; +import { authUsers, companyMemberships, type Db } from "@paperclipai/db"; +import type { WorkspaceReadiness, WorkspaceReadinessProbeResult } from "@paperclipai/shared"; +import { + deriveWorkspaceHandoffKey, + deriveWorkspaceReadinessToken, + resolveWorkspaceHandoffRootSecret, + WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY, + WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY, + WORKSPACE_HANDOFF_KEY_ENV_KEY, + WORKSPACE_READINESS_TOKEN_ENV_KEY, + WORKSPACE_READINESS_TOKEN_HEADER, + WORKSPACE_READINESS_USER_EMAIL_HEADER, + WORKSPACE_READINESS_USER_ID_HEADER, +} from "../auth/workspace-login-handoff.js"; +import { logger } from "../middleware/logger.js"; +import { + deriveWorktreeInstanceId, + readWorktreeInstanceId, +} from "./workspace-instance-cleanup.js"; + +/** + * Per-probe budget. Matched to the semantic transport probe it replaces, so + * upgrading a runtime health check to the readiness contract cannot make a + * managed start or a reuse decision slower than it was. + */ +export const WORKSPACE_READINESS_PROBE_TIMEOUT_MS = 2_000; + +export type ManagedWorkspaceIdentity = { + instanceId: string; + executionWorkspaceId: string; + companyId: string; + handoffKey: string; + readinessToken: string; + /** Whether the root secret was configured explicitly or derived. */ + secretSource: "dedicated" | "derived"; +}; + +/** + * Resolve the isolated instance id a worktree actually runs as. + * + * The worktree's `.paperclip/.env` pointer is authoritative because it is what + * the guest process itself loads. The path-derived id is only a fallback for a + * worktree provisioned before the pointer existed, and the two agree by + * construction for anything Paperclip provisioned. + */ +export function resolveManagedWorkspaceInstanceId(workspaceCwd: string): string | null { + const recorded = readWorktreeInstanceId(workspaceCwd); + if (recorded) return recorded; + const derived = deriveWorktreeInstanceId(workspaceCwd); + return derived || null; +} + +export function resolveManagedWorkspaceIdentity(input: { + workspaceCwd: string | null | undefined; + executionWorkspaceId: string | null | undefined; + companyId: string | null | undefined; + env?: NodeJS.ProcessEnv; +}): ManagedWorkspaceIdentity | null { + const workspaceCwd = input.workspaceCwd?.trim(); + const executionWorkspaceId = input.executionWorkspaceId?.trim(); + const companyId = input.companyId?.trim(); + if (!workspaceCwd || !executionWorkspaceId || !companyId) return null; + + const root = resolveWorkspaceHandoffRootSecret(input.env ?? process.env); + if (!root) return null; + + const instanceId = resolveManagedWorkspaceInstanceId(path.resolve(workspaceCwd)); + if (!instanceId) return null; + + return { + instanceId, + executionWorkspaceId, + companyId, + handoffKey: deriveWorkspaceHandoffKey({ rootSecret: root.secret, instanceId, executionWorkspaceId }), + readinessToken: deriveWorkspaceReadinessToken({ rootSecret: root.secret, instanceId, executionWorkspaceId }), + secretSource: root.source, + }; +} + +/** + * Env injected into a managed workspace's guest process. + * + * Only the *derived* per-workspace values cross the boundary: the guest never + * receives the root secret, so a compromised workspace cannot mint a ticket for + * a sibling workspace. + */ +export function buildManagedWorkspaceGuestEnv(identity: ManagedWorkspaceIdentity): Record { + return { + [WORKSPACE_HANDOFF_KEY_ENV_KEY]: identity.handoffKey, + [WORKSPACE_READINESS_TOKEN_ENV_KEY]: identity.readinessToken, + [WORKSPACE_EXECUTION_WORKSPACE_ID_ENV_KEY]: identity.executionWorkspaceId, + [WORKSPACE_EXECUTION_WORKSPACE_COMPANY_ID_ENV_KEY]: identity.companyId, + }; +} + +function isWorkspaceReadinessShape(value: unknown): value is WorkspaceReadiness { + if (!value || typeof value !== "object") return false; + const candidate = value as Record; + return ( + typeof candidate.state === "string" + && typeof candidate.databaseReady === "boolean" + && typeof candidate.cloneDataReady === "boolean" + && typeof candidate.authHandoffReady === "boolean" + && (typeof candidate.authHandoffUserId === "string" || candidate.authHandoffUserId === null) + && typeof candidate.seedState === "string" + && (typeof candidate.companyId === "string" || candidate.companyId === null) + ); +} + +/** + * Read a managed workspace's protected readiness over loopback. + * + * Failure modes are distinguished so callers can tell "not reachable" from + * "reachable but serving somebody else's clone" — the second is what made a + * relocated port able to masquerade as healthy. + */ +export async function probeManagedWorkspaceReadiness(input: { + healthUrl: string; + identity: ManagedWorkspaceIdentity; + fetchImpl?: typeof fetch; + timeoutMs?: number; + handoffSubject?: { userId: string; email: string } | null; +}): Promise { + const fetchImpl = input.fetchImpl ?? fetch; + let response: Response; + try { + response = await fetchImpl(input.healthUrl, { + headers: { + [WORKSPACE_READINESS_TOKEN_HEADER]: input.identity.readinessToken, + ...(input.handoffSubject + ? { + [WORKSPACE_READINESS_USER_ID_HEADER]: input.handoffSubject.userId, + [WORKSPACE_READINESS_USER_EMAIL_HEADER]: input.handoffSubject.email, + } + : {}), + }, + signal: AbortSignal.timeout(input.timeoutMs ?? WORKSPACE_READINESS_PROBE_TIMEOUT_MS), + }); + } catch (error) { + return { + ok: false, + reason: "unreachable", + readiness: null, + detail: error instanceof Error ? error.message : String(error), + }; + } + + const payload = await response.json().catch(() => null) as + | { status?: unknown; workspace?: unknown } + | null; + + if (!response.ok) { + const readiness = isWorkspaceReadinessShape(payload?.workspace) ? payload.workspace : null; + return { ok: false, reason: "http_error", readiness, detail: `HTTP ${response.status}` }; + } + if (payload?.status !== "ok") { + return { ok: false, reason: "unhealthy_payload", readiness: null, detail: String(payload?.status ?? "missing") }; + } + if ( + payload.workspace + && typeof payload.workspace === "object" + && !("companyId" in payload.workspace) + ) { + // A guest that implements readiness but predates the company binding is not + // a legacy transport-only guest. Treat the partial contract as an identity + // disagreement so auto compatibility mode cannot publish an unopenable clone. + return { + ok: false, + reason: "identity_mismatch", + readiness: null, + detail: `expected company ${input.identity.companyId}, got missing`, + }; + } + if ( + input.handoffSubject + && payload.workspace + && typeof payload.workspace === "object" + && !("authHandoffUserId" in payload.workspace) + ) { + return { + ok: false, + reason: "identity_mismatch", + readiness: null, + detail: `expected handoff user ${input.handoffSubject.userId}, got missing`, + }; + } + if (!isWorkspaceReadinessShape(payload.workspace)) { + // Either the token was rejected or the guest predates this contract. Both + // mean the control plane cannot prove user readiness, so neither may publish. + return { ok: false, reason: "readiness_missing", readiness: null, detail: null }; + } + + const readiness = payload.workspace; + if ( + readiness.instanceId !== input.identity.instanceId + || readiness.executionWorkspaceId !== input.identity.executionWorkspaceId + || readiness.companyId !== input.identity.companyId + ) { + return { + ok: false, + reason: "identity_mismatch", + readiness, + detail: `expected ${input.identity.instanceId}/${input.identity.executionWorkspaceId}/${input.identity.companyId}, got ${readiness.instanceId}/${readiness.executionWorkspaceId}/${readiness.companyId}`, + }; + } + if (!readiness.databaseReady || !readiness.cloneDataReady || !readiness.authHandoffReady) { + return { ok: false, reason: "not_ready", readiness, detail: readiness.failurePhase }; + } + if (input.handoffSubject && readiness.authHandoffUserId !== input.handoffSubject.userId) { + return { + ok: false, + reason: "identity_mismatch", + readiness, + detail: `expected handoff user ${input.handoffSubject.userId}, got ${readiness.authHandoffUserId}`, + }; + } + + return { ok: true, readiness }; +} + +/** Current board identities that must survive the clone for it to publish ready. */ +export async function listManagedWorkspaceHandoffSubjects( + db: Db, + companyId: string, +): Promise> { + const rows = await db + .select({ userId: authUsers.id, email: authUsers.email }) + .from(authUsers) + .innerJoin( + companyMemberships, + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, authUsers.id), + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.status, "active"), + ), + ); + return rows.flatMap((row) => { + const userId = row.userId?.trim(); + const email = row.email?.trim(); + return userId && email ? [{ userId, email }] : []; + }); +} + +/** Prove that every current board member can use the advertised handoff. */ +export async function probeManagedWorkspaceHandoffSubjects(input: { + db: Db; + healthUrl: string; + identity: ManagedWorkspaceIdentity; + fetchImpl?: typeof fetch; +}): Promise { + const subjects = await listManagedWorkspaceHandoffSubjects(input.db, input.identity.companyId); + if (subjects.length === 0) { + return { + ok: false, + reason: "not_ready", + readiness: null, + detail: "no active board user is eligible for workspace login handoff", + }; + } + const results = await Promise.all(subjects.map((handoffSubject) => probeManagedWorkspaceReadiness({ + healthUrl: input.healthUrl, + identity: input.identity, + fetchImpl: input.fetchImpl, + handoffSubject, + }))); + return results.find((result) => !result.ok) ?? results[0]!; +} + +/** + * How strictly a managed start is gated on the guest's protected readiness. + * + * - `auto` (default): a guest that *reports* readiness must satisfy it, and a + * guest serving a different instance/workspace is always rejected. A guest + * that reports no readiness block at all — an older checkout, which a managed + * worktree can legitimately be — falls back to the semantic transport check + * and is logged. Failing that case closed would turn an upgrade lag into an + * outage of every existing workspace, which is a worse reliability outcome + * than the defect this gate exists to fix. Such a workspace is still not + * silently "usable": it has no handoff plugin, so `Open workspace` reports the + * snapshot-local credential fallback rather than pretending to be ready. + * - `strict`: a missing readiness block also blocks publication. Intended for + * deployments that have finished rolling the new guest build everywhere. + */ +export type WorkspaceReadinessGateMode = "auto" | "strict"; + +export function resolveWorkspaceReadinessGateMode( + env: NodeJS.ProcessEnv = process.env, +): WorkspaceReadinessGateMode { + return env.PAPERCLIP_WORKSPACE_READINESS_GATE?.trim().toLowerCase() === "strict" ? "strict" : "auto"; +} + +/** + * Whether a rejected probe must stop the runtime from being published. + * + * Everything except "this guest does not implement the contract" is a real + * disagreement about the clone and always blocks. + */ +export function shouldBlockPublicationOnReadiness( + result: Extract, + mode: WorkspaceReadinessGateMode = resolveWorkspaceReadinessGateMode(), +): boolean { + if (result.reason === "readiness_missing") return mode === "strict"; + return true; +} + +/** + * How long a freshly started workspace may take to satisfy readiness. + * + * Deliberately short. The transport readiness probe has already required a + * `status: ok` response from this guest by the time the gate runs, so the only + * thing left to absorb is a one-off blip — not a cold start. A longer budget + * would add latency to every managed start to no benefit. + */ +export const WORKSPACE_READINESS_GATE_TIMEOUT_MS = 3_000; +const WORKSPACE_READINESS_GATE_INTERVAL_MS = 250; + +/** + * Poll readiness until it passes or the gate budget runs out. + * + * `identity_mismatch` and `readiness_missing` are terminal and return + * immediately: another instance owning the port, or a guest that does not + * implement the contract, are not conditions that polling can change. + */ +export async function waitForManagedWorkspaceReadiness(input: { + healthUrl: string; + identity: ManagedWorkspaceIdentity; + fetchImpl?: typeof fetch; + timeoutMs?: number; + now?: () => number; + sleep?: (ms: number) => Promise; +}): Promise { + const now = input.now ?? Date.now; + const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); })); + const deadline = now() + (input.timeoutMs ?? WORKSPACE_READINESS_GATE_TIMEOUT_MS); + let last: WorkspaceReadinessProbeResult = { + ok: false, + reason: "unreachable", + readiness: null, + detail: "readiness gate did not run", + }; + for (;;) { + last = await probeManagedWorkspaceReadiness({ + healthUrl: input.healthUrl, + identity: input.identity, + fetchImpl: input.fetchImpl, + }); + if (last.ok || last.reason === "identity_mismatch" || last.reason === "readiness_missing") return last; + if (now() >= deadline) return last; + await sleep(WORKSPACE_READINESS_GATE_INTERVAL_MS); + } +} + +/** Log a rejected probe once, with the machine reason kept intact for operators. */ +export function logManagedWorkspaceReadinessRejection(input: { + executionWorkspaceId: string; + healthUrl: string; + result: Extract; +}) { + logger.warn( + { + executionWorkspaceId: input.executionWorkspaceId, + healthUrl: input.healthUrl, + reason: input.result.reason, + detail: input.result.detail, + seedState: input.result.readiness?.seedState ?? null, + failurePhase: input.result.readiness?.failurePhase ?? null, + }, + "managed workspace readiness probe rejected the runtime", + ); +} diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 2a020c9c05b..45809b23b56 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -2,9 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Db } from "@paperclipai/db"; import type { NativeExecutionInputV1 } from "@paperclipai/paperclip-runner"; +type BackendFactoryOptions = { + runnerInstanceId?: string; + onSpawn?: (meta: { + pid: number; + processGroupId: number | null; + startedAt: string; + }) => Promise; +}; + const state = vi.hoisted(() => ({ execute: vi.fn(), - createBackend: vi.fn(() => ({ kind: "test" })), + createBackend: vi.fn( + (_input: NativeExecutionInputV1, _options: BackendFactoryOptions) => ({ + kind: "test", + }), + ), cancel: vi.fn(), release: null as null | (() => void), })); 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/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; 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); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 806bc85c2b3..1fc64a0b638 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -49,8 +49,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"; @@ -5240,19 +5240,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; 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; 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(); + 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/server/src/services/work-products.ts b/server/src/services/work-products.ts index 55d2a8c714d..8266a5174a0 100644 --- a/server/src/services/work-products.ts +++ b/server/src/services/work-products.ts @@ -1,6 +1,6 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { issueWorkProducts } from "@paperclipai/db"; +import { issueWorkProducts, workspaceRuntimeServices } from "@paperclipai/db"; import type { IssueWorkProduct } from "@paperclipai/shared"; import { insertRowsInChunks } from "./batch-insert.js"; import type { ImportIssueWorkProductRow } from "./import-write-types.js"; @@ -33,6 +33,44 @@ function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct { }; } +/** + * Refresh runtime-service work products from the live runtime rows they point at + * (PAP-17572). + * + * A runtime URL is only valid for as long as the process holds that port. A + * managed restart can relocate it, which used to leave a user-facing preview link + * that answers with somebody else's service or nothing at all. The runtime row is + * the authoritative publication record, so it wins over the stored copy. + * + * Read-path only and deliberately non-destructive: a work product whose runtime + * row is gone keeps its recorded URL and is reported unhealthy rather than + * silently blanked, so the history of what was published survives. + */ +export function reconcileRuntimeServiceWorkProducts( + products: IssueWorkProduct[], + liveRuntimeServices: Array<{ + id: string; + url: string | null; + status: string; + healthStatus: string; + }>, +): IssueWorkProduct[] { + if (products.length === 0) return products; + const liveById = new Map(liveRuntimeServices.map((service) => [service.id, service])); + return products.map((product) => { + if (product.type !== "runtime_service" || !product.runtimeServiceId) return product; + const live = liveById.get(product.runtimeServiceId); + if (!live) { + return product.healthStatus === "unhealthy" ? product : { ...product, healthStatus: "unhealthy" }; + } + const isServing = live.status === "running" && live.healthStatus === "healthy"; + const url = live.url ?? product.url; + const healthStatus: IssueWorkProduct["healthStatus"] = isServing ? "healthy" : "unhealthy"; + if (product.url === url && product.healthStatus === healthStatus) return product; + return { ...product, url, healthStatus }; + }); +} + export function workProductService(db: Db) { return { listForIssue: async (issueId: string) => { @@ -41,7 +79,21 @@ export function workProductService(db: Db) { .from(issueWorkProducts) .where(eq(issueWorkProducts.issueId, issueId)) .orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt)); - return rows.map(toIssueWorkProduct); + const products = rows.map(toIssueWorkProduct); + const runtimeServiceIds = products + .map((product) => (product.type === "runtime_service" ? product.runtimeServiceId : null)) + .filter((value): value is string => Boolean(value)); + if (runtimeServiceIds.length === 0) return products; + const liveRuntimeServices = await db + .select({ + id: workspaceRuntimeServices.id, + url: workspaceRuntimeServices.url, + status: workspaceRuntimeServices.status, + healthStatus: workspaceRuntimeServices.healthStatus, + }) + .from(workspaceRuntimeServices) + .where(inArray(workspaceRuntimeServices.id, [...new Set(runtimeServiceIds)])); + return reconcileRuntimeServiceWorkProducts(products, liveRuntimeServices); }, getById: async (id: string) => { diff --git a/server/src/services/workspace-git-operation-scheduler.test.ts b/server/src/services/workspace-git-operation-scheduler.test.ts index 61b816c06fd..abd9a36930d 100644 --- a/server/src/services/workspace-git-operation-scheduler.test.ts +++ b/server/src/services/workspace-git-operation-scheduler.test.ts @@ -165,14 +165,21 @@ describe("WorkspaceGitOperationScheduler", () => { const scheduler = createWorkspaceGitOperationScheduler({ runner, defaultCacheTtlMs: 0 }); const first = scheduler.run(scanInput(workspace, "same")); + // Wait for the first request to register as the single-flight leader before + // the alias request starts. Both requests call fs.realpath concurrently, so + // without this barrier the symlink alias can resolve first and become the + // leader. That race makes the leader and joiner order non-deterministic. + await vi.waitFor(() => expect(scheduler.snapshot().inFlightCount).toBe(1)); const joined = scheduler.run(scanInput(alias, "same")); await vi.waitFor(() => expect(scheduler.snapshot().totals.singleFlightJoins).toBe(1)); expect(calls).toBe(1); gate.resolve(); - await expect(Promise.all([first, joined])).resolves.toEqual([ - expect.objectContaining({ stdout: "shared", singleFlightJoined: false }), - expect.objectContaining({ stdout: "shared", singleFlightJoined: true }), + const results = await Promise.all([first, joined]); + expect(results).toEqual([ + expect.objectContaining({ stdout: "shared" }), + expect.objectContaining({ stdout: "shared" }), ]); + expect(results.map((result) => result.singleFlightJoined).sort()).toEqual([false, true]); shouldFail = true; await expect(scheduler.run(scanInput(workspace, "failure"))).rejects.toMatchObject({ diff --git a/server/src/services/workspace-instance-cleanup.ts b/server/src/services/workspace-instance-cleanup.ts index bcfe06f5b02..e2c08e07e67 100644 --- a/server/src/services/workspace-instance-cleanup.ts +++ b/server/src/services/workspace-instance-cleanup.ts @@ -1,5 +1,6 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -27,6 +28,27 @@ export function deriveWorktreeInstanceId(workspacePath: string): string { return `${prefix}-${pathHash}`; } +/** + * The instance id a seeded worktree actually runs as, read from the pointer the + * guest process itself loads. + * + * Synchronous and null-on-anything-unexpected by design: callers use it to + * decide whether they can prove workspace identity at all, and an unsafe or + * unreadable pointer must fail closed rather than fall back to a guess. + */ +export function readWorktreeInstanceId(workspacePath: string): string | null { + const envPath = path.join(path.resolve(workspacePath), ".paperclip", ".env"); + let contents: string; + try { + contents = readFileSync(envPath, "utf8"); + } catch { + return null; + } + const instanceId = parseEnvContents(contents).PAPERCLIP_INSTANCE_ID?.trim(); + if (!instanceId || !INSTANCE_ID_RE.test(instanceId)) return null; + return instanceId; +} + export type WorktreeInstancePointer = { envPath: string; envContents: string; @@ -289,9 +311,12 @@ export async function cleanupWorktreeInstanceArtifacts(input: { return { status: "refused", instanceRoot: configuredInstanceRoot, warning }; } - const expectedInstanceRoot = input.expectedInstanceRoot - ? path.resolve(input.expectedInstanceRoot) - : null; + const expectedInstanceRoot = input.expectedInstanceRoot ? path.resolve(input.expectedInstanceRoot) : null; + if (!expectedInstanceRoot) { + warning = `Refusing to remove instance directory "${configuredInstanceRoot}" because execution workspace ${input.workspaceId} has no persisted instance root.`; + await recordRefusal({ refusalReason: "persisted_instance_root_missing" }); + return { status: "refused", instanceRoot: configuredInstanceRoot, warning }; + } if (expectedInstanceRoot && configuredInstanceRoot !== expectedInstanceRoot) { warning = `Refusing to remove instance directory "${configuredInstanceRoot}" because it does not match execution workspace ${input.workspaceId}'s persisted instance root "${expectedInstanceRoot}".`; await recordRefusal({ diff --git a/server/src/services/workspace-login-handoff-issuer.ts b/server/src/services/workspace-login-handoff-issuer.ts new file mode 100644 index 00000000000..3dd2b295d4e --- /dev/null +++ b/server/src/services/workspace-login-handoff-issuer.ts @@ -0,0 +1,215 @@ +/** + * Issues signed workspace login handoff tickets (PAP-17572). + * + * Runs on the authenticated main control plane. Everything a ticket is bound to + * is resolved here from server-side state — the caller's authenticated identity, + * the live runtime row's published URL, and the worktree's recorded instance + * pointer — so a client cannot influence the audience, workspace, or instance a + * ticket is valid for. + * + * A ticket is only minted for a workspace that currently passes the protected + * readiness probe. Handing out a ticket for a half-restored clone would turn a + * diagnosable "clone is incomplete" state back into the opaque + * "invalid email or password" that started this incident. + */ + +import { and, desc, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { authUsers, instanceUserRoles, workspaceRuntimeServices } from "@paperclipai/db"; +import type { WorkspaceReadiness } from "@paperclipai/shared"; +import { + buildWorkspaceHandoffExchangeUrl, + issueWorkspaceHandoffTicket, + normalizeWorkspaceHandoffOrigin, + sanitizeWorkspaceHandoffRedirectPath, + WORKSPACE_HANDOFF_TICKET_TTL_SECONDS, +} from "../auth/workspace-login-handoff.js"; +import { resolvePaperclipInstanceId } from "../home-paths.js"; +import { + probeManagedWorkspaceReadiness, + resolveManagedWorkspaceIdentity, +} from "./managed-workspace-identity.js"; + +export type WorkspaceLoginHandoffActor = { + userId: string | null | undefined; + userEmail: string | null | undefined; + source: string | null | undefined; +}; + +export type WorkspaceLoginHandoffFailure = + | { reason: "no_board_identity" } + | { reason: "handoff_not_configured" } + | { reason: "runtime_not_running" } + | { reason: "runtime_url_unusable"; detail: string } + | { reason: "workspace_not_ready"; detail: string | null; readiness: WorkspaceReadiness | null }; + +export type WorkspaceLoginHandoffIssuance = { + url: string; + expiresAt: string; + /** The live runtime origin the ticket was bound to. */ + origin: string; + nonce: string; + userId: string; + instanceId: string; +}; + +export type WorkspaceLoginHandoffResult = + | { ok: true; issuance: WorkspaceLoginHandoffIssuance } + | { ok: false; failure: WorkspaceLoginHandoffFailure }; + +/** + * Resolve the board user a ticket will be minted for. + * + * In `authenticated` mode this is the caller's own session identity. The + * `local_implicit` actor of a `local_trusted` instance has no auth row, so the + * instance's oldest instance admin stands in: that mode already grants + * unrestricted board access to this instance and its clones, so this widens + * nothing, and it keeps local development password-independent too. + */ +export async function resolveWorkspaceHandoffBoardIdentity( + db: Db, + actor: WorkspaceLoginHandoffActor, +): Promise<{ userId: string; email: string } | null> { + const userId = actor.userId?.trim(); + const email = actor.userEmail?.trim(); + if (userId && email && actor.source !== "local_implicit") { + return { userId, email }; + } + if (actor.source !== "local_implicit") return null; + + const admin = await db + .select({ id: authUsers.id, email: authUsers.email, createdAt: authUsers.createdAt }) + .from(authUsers) + .innerJoin( + instanceUserRoles, + and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")), + ) + .orderBy(authUsers.createdAt, authUsers.id) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!admin?.email) return null; + return { userId: admin.id, email: admin.email }; +} + +/** + * The live runtime row a workspace is currently served from. + * + * Reading the row (rather than a stored work-product URL) is what keeps the + * handoff target correct after a port change: the row is the same source the + * runtime publication path writes. + */ +export async function resolveLiveWorkspaceRuntimeOrigin( + db: Db, + input: { companyId: string; executionWorkspaceId: string }, +): Promise<{ url: string; runtimeServiceId: string } | null> { + const row = await db + .select({ + id: workspaceRuntimeServices.id, + url: workspaceRuntimeServices.url, + updatedAt: workspaceRuntimeServices.updatedAt, + }) + .from(workspaceRuntimeServices) + .where( + and( + eq(workspaceRuntimeServices.companyId, input.companyId), + eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId), + eq(workspaceRuntimeServices.status, "running"), + eq(workspaceRuntimeServices.healthStatus, "healthy"), + ), + ) + .orderBy(desc(workspaceRuntimeServices.updatedAt)) + .then((rows) => rows.find((candidate) => Boolean(candidate.url)) ?? null); + if (!row?.url) return null; + return { url: row.url, runtimeServiceId: row.id }; +} + +export async function issueWorkspaceLoginHandoff(input: { + db: Db; + companyId: string; + executionWorkspace: { id: string; cwd: string | null }; + actor: WorkspaceLoginHandoffActor; + next?: string | null; + ttlSeconds?: number; + now?: Date; + probe?: typeof probeManagedWorkspaceReadiness; +}): Promise { + const identity = resolveManagedWorkspaceIdentity({ + workspaceCwd: input.executionWorkspace.cwd, + executionWorkspaceId: input.executionWorkspace.id, + companyId: input.companyId, + }); + if (!identity) return { ok: false, failure: { reason: "handoff_not_configured" } }; + + const boardIdentity = await resolveWorkspaceHandoffBoardIdentity(input.db, input.actor); + if (!boardIdentity) return { ok: false, failure: { reason: "no_board_identity" } }; + + const live = await resolveLiveWorkspaceRuntimeOrigin(input.db, { + companyId: input.companyId, + executionWorkspaceId: input.executionWorkspace.id, + }); + if (!live) return { ok: false, failure: { reason: "runtime_not_running" } }; + + const origin = normalizeWorkspaceHandoffOrigin(live.url); + if (!origin) { + return { ok: false, failure: { reason: "runtime_url_unusable", detail: live.url } }; + } + + const probe = input.probe ?? probeManagedWorkspaceReadiness; + const readinessResult = await probe({ + // Probe the origin the user will actually be sent to, so a stale row or a + // reassigned port is caught before a ticket exists rather than after. + healthUrl: new URL("/api/health", origin).toString(), + identity, + handoffSubject: boardIdentity, + }); + if (!readinessResult.ok) { + return { + ok: false, + failure: { + reason: "workspace_not_ready", + detail: readinessResult.detail ?? readinessResult.reason, + readiness: readinessResult.readiness, + }, + }; + } + + const { ticket, payload } = issueWorkspaceHandoffTicket({ + key: identity.handoffKey, + userId: boardIdentity.userId, + email: boardIdentity.email, + executionWorkspaceId: identity.executionWorkspaceId, + companyId: identity.companyId, + instanceId: identity.instanceId, + origin, + issuerInstanceId: resolvePaperclipInstanceId(), + next: sanitizeWorkspaceHandoffRedirectPath(input.next), + ttlSeconds: input.ttlSeconds ?? WORKSPACE_HANDOFF_TICKET_TTL_SECONDS, + now: input.now, + }); + + return { + ok: true, + issuance: { + url: buildWorkspaceHandoffExchangeUrl({ origin, ticket }), + expiresAt: new Date(payload.exp * 1000).toISOString(), + origin, + nonce: payload.jti, + userId: boardIdentity.userId, + instanceId: identity.instanceId, + }, + }; +} + +/** HTTP status for a failed issuance. */ +export function workspaceLoginHandoffFailureStatus(failure: WorkspaceLoginHandoffFailure): number { + switch (failure.reason) { + case "no_board_identity": + return 403; + case "handoff_not_configured": + return 501; + case "runtime_not_running": + case "runtime_url_unusable": + case "workspace_not_ready": + return 409; + } +} diff --git a/server/src/services/workspace-operations.ts b/server/src/services/workspace-operations.ts index 384f5785ee8..773744a40d7 100644 --- a/server/src/services/workspace-operations.ts +++ b/server/src/services/workspace-operations.ts @@ -14,7 +14,7 @@ type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect; * Managed runtime control actions. Every one of these mutates the runtime rows and * local listeners of a single execution workspace, so only one may be live at a time. */ -const RUNTIME_CONTROL_ACTIONS = new Set(["start", "stop", "restart", "run"]); +const RUNTIME_CONTROL_ACTIONS = new Set(["start", "stop", "restart", "repair", "run"]); /** * Identity of this server process. A `running` runtime-control operation stamped with a @@ -234,7 +234,10 @@ export interface WorkspaceOperationRecorder { * timeout error is rethrown, so a hung provider can never leave an active operation. */ timeoutMs?: number | null; - run: () => Promise<{ + run: (reportProgress: (input: { + metadata?: Record | null; + system?: string | null; + }) => Promise) => Promise<{ status?: WorkspaceOperationStatus; exitCode?: number | null; stdout?: string | null; @@ -484,7 +487,7 @@ export function workspaceOperationService(db: Db) { // Managed runtime controls get an ownership stamp so bounded recovery can tell a // slow-but-live operation from one abandoned by a dead request or server process. const runtimeControlAction = readRuntimeControlAction(recordInput.metadata); - const insertedMetadata = runtimeControlAction + let currentMetadata = runtimeControlAction ? { ...(recordInput.metadata ?? {}), runtimeControlOwner: { @@ -515,7 +518,7 @@ export function workspaceOperationService(db: Db) { logStore: handle.store, logRef: handle.logRef, metadata: redactCurrentUserValue( - insertedMetadata, + currentMetadata, currentUserRedactionOptions, ) as Record | null, startedAt, @@ -532,17 +535,21 @@ export function workspaceOperationService(db: Db) { if (runtimeControlAction) { heartbeatTimer = setInterval(() => { const heartbeatAt = new Date(); + currentMetadata = combineMetadata(currentMetadata, { + runtimeControlOwner: { + ownerId: RUNTIME_CONTROL_OWNER_ID, + pid: process.pid, + action: runtimeControlAction, + heartbeatAt: heartbeatAt.toISOString(), + } satisfies RuntimeControlOwnerStamp, + }); void db .update(workspaceOperations) .set({ - metadata: combineMetadata(insertedMetadata, { - runtimeControlOwner: { - ownerId: RUNTIME_CONTROL_OWNER_ID, - pid: process.pid, - action: runtimeControlAction, - heartbeatAt: heartbeatAt.toISOString(), - } satisfies RuntimeControlOwnerStamp, - }), + metadata: redactCurrentUserValue( + currentMetadata, + currentUserRedactionOptions, + ) as Record | null, updatedAt: heartbeatAt, }) .where(and(eq(workspaceOperations.id, id), eq(workspaceOperations.status, "running"))) @@ -551,9 +558,29 @@ export function workspaceOperationService(db: Db) { heartbeatTimer.unref?.(); } + const reportProgress = async (progress: { + metadata?: Record | null; + system?: string | null; + }) => { + await append("system", progress.system ?? null); + currentMetadata = combineMetadata(currentMetadata, progress.metadata); + await db + .update(workspaceOperations) + .set({ + metadata: redactCurrentUserValue( + currentMetadata, + currentUserRedactionOptions, + ) as Record | null, + stdoutExcerpt: stdoutExcerpt || null, + stderrExcerpt: stderrExcerpt || null, + updatedAt: new Date(), + }) + .where(and(eq(workspaceOperations.id, id), eq(workspaceOperations.status, "running"))); + }; + const timeoutMs = recordInput.timeoutMs ?? defaultRuntimeControlTimeoutMs(runtimeControlAction); const settle = async () => { - if (!timeoutMs || timeoutMs <= 0) return await recordInput.run(); + if (!timeoutMs || timeoutMs <= 0) return await recordInput.run(reportProgress); const timeout = new Promise((_resolve, reject) => { timeoutTimer = setTimeout( () => reject(new WorkspaceOperationTimeoutError(timeoutMs, runtimeControlAction)), @@ -561,7 +588,7 @@ export function workspaceOperationService(db: Db) { ); timeoutTimer.unref?.(); }); - return await Promise.race([recordInput.run(), timeout]); + return await Promise.race([recordInput.run(reportProgress), timeout]); }; try { @@ -583,7 +610,7 @@ export function workspaceOperationService(db: Db) { logSha256: finalized.sha256, logCompressed: finalized.compressed, metadata: redactCurrentUserValue( - combineMetadata(insertedMetadata, result.metadata), + combineMetadata(currentMetadata, result.metadata), currentUserRedactionOptions, ) as Record | null, finishedAt, @@ -613,7 +640,7 @@ export function workspaceOperationService(db: Db) { ...(runtimeControlAction ? { metadata: redactCurrentUserValue( - combineMetadata(insertedMetadata, { + combineMetadata(currentMetadata, { failureReason: error instanceof WorkspaceOperationTimeoutError ? "runtime_control_timeout" : "runtime_control_error", diff --git a/server/src/services/workspace-readiness.ts b/server/src/services/workspace-readiness.ts new file mode 100644 index 00000000000..500124850c9 --- /dev/null +++ b/server/src/services/workspace-readiness.ts @@ -0,0 +1,296 @@ +/** + * Protected workspace readiness (PAP-17572). + * + * `/api/health` answering 200 only proves a listener exists. This module answers + * the question the control plane and the UI actually need — "can a board user + * open this clone and see their data?" — from four independent signals: the + * isolated database, the versioned seed manifest, representative cloned rows, + * and the login-handoff configuration. + * + * Everything here is read-only and deliberately small: structural validation of + * a clone happens once, during provisioning or repair, not on every probe. + * + * `repairing` is intentionally not derivable here. A guest process cannot know + * that the control plane is running a managed repair against it; the UI layers + * that state on from the live workspace-operation row. + */ + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { and, eq, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { authUsers, companies, companyMemberships, issues } from "@paperclipai/db"; +import type { + WorkspaceReadiness, + WorkspaceReadinessState, + WorkspaceSeedReadinessState, +} from "@paperclipai/shared"; +import { + resolveWorkspaceHandoffLocalCompanyId, + resolveWorkspaceHandoffLocalKey, + resolveWorkspaceHandoffLocalWorkspaceId, +} from "../auth/workspace-login-handoff.js"; +import { resolvePaperclipInstanceId } from "../home-paths.js"; +import { logger } from "../middleware/logger.js"; + +const WORKSPACE_SEED_MANIFEST_BASENAME = "seed-manifest.json"; +const LEGACY_SEED_PENDING_BASENAME = "seed-pending"; +const LEGACY_SEED_COMPLETE_BASENAME = "seed-complete"; + +type SeedManifestSummary = { + state: WorkspaceSeedReadinessState; + phase: string | null; + mode: "minimal" | "full" | null; + failurePhase: string | null; +}; + +/** + * Directory holding this instance's seed markers. + * + * `PAPERCLIP_CONFIG` is the authoritative pointer a seeded worktree is started + * with; the cwd fallback covers a guest launched without it. + */ +export function resolveWorkspaceSeedMarkerDir(env: NodeJS.ProcessEnv = process.env): string { + const configPath = env.PAPERCLIP_CONFIG?.trim(); + if (configPath) return path.dirname(path.resolve(configPath)); + return path.resolve(process.cwd(), ".paperclip"); +} + +function readSeedManifestSummary(markerDir: string): SeedManifestSummary { + const manifestPath = path.join(markerDir, WORKSPACE_SEED_MANIFEST_BASENAME); + if (existsSync(manifestPath)) { + let manifest: Record; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + } catch { + // A manifest we cannot parse is evidence of an interrupted write, which is + // a failure — never a silent "assume seeded". + return { state: "failed", phase: null, mode: null, failurePhase: "seed_manifest_unreadable" }; + } + const rawState = typeof manifest.state === "string" ? manifest.state : null; + const state: WorkspaceSeedReadinessState = + rawState === "pending" || rawState === "running" || rawState === "verified" || rawState === "failed" + ? rawState + : "unknown"; + const phase = typeof manifest.phase === "string" ? manifest.phase : null; + const mode = manifest.seedMode === "minimal" || manifest.seedMode === "full" ? manifest.seedMode : null; + return { + state, + phase, + mode, + failurePhase: state === "failed" ? phase ?? "unknown" : null, + }; + } + + // Legacy markers predate the versioned manifest. `seed-complete`, or the + // absence of both markers on an already-running instance, means an adopted + // legacy clone whose completeness this probe cannot prove. + if (existsSync(path.join(markerDir, LEGACY_SEED_PENDING_BASENAME))) { + return { state: "pending", phase: "pending", mode: null, failurePhase: null }; + } + if (existsSync(path.join(markerDir, LEGACY_SEED_COMPLETE_BASENAME))) { + return { state: "unknown", phase: "legacy_complete_marker", mode: null, failurePhase: null }; + } + return { state: "absent", phase: null, mode: null, failurePhase: null }; +} + +/** + * Collapse the individual signals into one state. + * + * `degraded` is reserved for "the clone exists and was verified, but a signal + * regressed", which is the case an operator can fix with one bounded repair. + * A clone that never finished reports `provisioning`/`validating`/`failed` + * instead, so the UI never offers a repair for work that is still in progress. + */ +export function resolveWorkspaceReadinessState(input: { + databaseReady: boolean; + cloneDataReady: boolean; + authHandoffReady: boolean; + seedState: WorkspaceSeedReadinessState; +}): WorkspaceReadinessState { + if (input.seedState === "failed") return "failed"; + if (input.seedState === "pending") return "provisioning"; + if (input.seedState === "running") return "provisioning"; + if (!input.databaseReady) return input.seedState === "verified" ? "degraded" : "provisioning"; + if (!input.cloneDataReady || !input.authHandoffReady) { + return input.seedState === "verified" ? "degraded" : "validating"; + } + // Cloned data and handoff both check out. A legacy or absent manifest is not + // proof of a verified seed, so surface it as validating rather than ready. + if (input.seedState !== "verified") return "validating"; + return "ready"; +} + +/** + * Whether this process is a cloned workspace instance at all. + * + * The primary control plane has no seed manifest and no injected workspace + * identity, and reporting a hollow readiness block for it would invite consumers + * to treat "no clone" as "broken clone". Detection is by evidence on disk or + * injected identity, never by a branch-name or path heuristic. + */ +let managedWorkspaceInstanceCache: { markerDir: string; value: boolean; checkedAtMs: number } | null = null; + +/** + * How long the marker-file answer is reused. `/api/health` is polled by the dev + * runner, the control plane's readiness probe and the UI, so three `existsSync` + * calls per request would be pure overhead on an instance whose answer is + * effectively static. Short enough that a manifest appearing mid-provision is + * picked up promptly. + */ +const MANAGED_WORKSPACE_DETECTION_TTL_MS = 5_000; + +export function isManagedWorkspaceInstance( + env: NodeJS.ProcessEnv = process.env, + now: () => number = Date.now, +): boolean { + // Injected identity is authoritative and free to read, so it short-circuits + // ahead of any filesystem work. + if (resolveWorkspaceHandoffLocalKey(env)) return true; + if (resolveWorkspaceHandoffLocalWorkspaceId(env)) return true; + + const markerDir = resolveWorkspaceSeedMarkerDir(env); + const cached = managedWorkspaceInstanceCache; + if ( + cached + && cached.markerDir === markerDir + && now() - cached.checkedAtMs < MANAGED_WORKSPACE_DETECTION_TTL_MS + ) { + return cached.value; + } + const value = existsSync(path.join(markerDir, WORKSPACE_SEED_MANIFEST_BASENAME)) + || existsSync(path.join(markerDir, LEGACY_SEED_PENDING_BASENAME)) + || existsSync(path.join(markerDir, LEGACY_SEED_COMPLETE_BASENAME)); + managedWorkspaceInstanceCache = { markerDir, value, checkedAtMs: now() }; + return value; +} + +/** Test-only seam so a suite can change marker files without waiting out the TTL. */ +export function resetManagedWorkspaceInstanceCacheForTests(): void { + managedWorkspaceInstanceCache = null; +} + +export type WorkspaceReadinessDeps = { + db: Db | null | undefined; + env?: NodeJS.ProcessEnv; + handoffSubject?: { userId: string; email: string } | null; +}; + +/** + * Assemble this instance's readiness. Never throws: a probe that cannot read the + * database must report `databaseReady: false`, not fail the health endpoint. + */ +export async function resolveWorkspaceReadiness(deps: WorkspaceReadinessDeps): Promise { + const env = deps.env ?? process.env; + const seed = readSeedManifestSummary(resolveWorkspaceSeedMarkerDir(env)); + const instanceId = resolvePaperclipInstanceId(); + const executionWorkspaceId = resolveWorkspaceHandoffLocalWorkspaceId(env); + // The company this workspace's board represents. Both product probes below are + // scoped to it: "some company in the clone has issues" and "some user has some + // membership" can both be true while the company the operator is opening is + // missing or has no members, which is a workspace that must not report ready. + const companyId = resolveWorkspaceHandoffLocalCompanyId(env); + const handoffKeyPresent = Boolean(resolveWorkspaceHandoffLocalKey(env)); + const handoffUserId = deps.handoffSubject?.userId.trim() || null; + const handoffUserEmail = deps.handoffSubject?.email.trim().toLowerCase() || null; + + let databaseReady = false; + let cloneDataReady = false; + let clonedAdminPresent = false; + let probeFailurePhase: string | null = null; + + if (deps.db) { + try { + await deps.db.execute(sql`SELECT 1`); + databaseReady = true; + } catch (error) { + logger.warn({ err: error }, "workspace readiness database probe failed"); + probeFailurePhase = "database_unreachable"; + } + + if (databaseReady && companyId) { + try { + // One representative cloned company/issue pair proves the restore carried + // product rows, not just an empty migrated schema. + const clonedRows = await deps.db + .select({ companyId: companies.id }) + .from(companies) + .innerJoin(issues, eq(issues.companyId, companies.id)) + .where(eq(companies.id, companyId)) + .limit(1) + .then((rows) => rows.length); + cloneDataReady = clonedRows > 0; + if (!cloneDataReady) probeFailurePhase ??= "clone_data_missing"; + } catch (error) { + logger.warn({ err: error }, "workspace readiness clone-data probe failed"); + probeFailurePhase ??= "clone_data_unreadable"; + } + + try { + // The handoff can only sign in a user who survived the clone with an + // active membership *in this workspace's company*, so readiness asserts + // that identity exists here rather than discovering it at click time. + // Existence, not a count: this runs on every protected health request and + // counting the whole join would scale with instance size for an answer + // that needs one row. + const eligibleUsers = await deps.db + .select({ userId: authUsers.id }) + .from(authUsers) + .innerJoin( + companyMemberships, + and( + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, authUsers.id), + eq(companyMemberships.status, "active"), + eq(companyMemberships.companyId, companyId), + ...(handoffUserId ? [eq(authUsers.id, handoffUserId)] : []), + ...(handoffUserEmail ? [sql`lower(${authUsers.email}) = ${handoffUserEmail}`] : []), + ), + ) + .limit(1) + .then((rows) => rows.length); + clonedAdminPresent = eligibleUsers > 0; + if (!clonedAdminPresent) probeFailurePhase ??= "cloned_membership_missing"; + } catch (error) { + logger.warn({ err: error }, "workspace readiness identity probe failed"); + probeFailurePhase ??= "cloned_identity_unreadable"; + } + } + } else { + probeFailurePhase = "database_not_configured"; + } + + const authHandoffReady = Boolean( + handoffKeyPresent + && executionWorkspaceId + && companyId + && clonedAdminPresent, + ); + if (!companyId) probeFailurePhase ??= "workspace_company_not_configured"; + if (!executionWorkspaceId) probeFailurePhase ??= "workspace_identity_not_configured"; + if (!handoffKeyPresent) probeFailurePhase ??= "auth_handoff_not_configured"; + + const state = resolveWorkspaceReadinessState({ + databaseReady, + cloneDataReady, + authHandoffReady, + seedState: seed.state, + }); + + return { + state, + databaseReady, + cloneDataReady, + authHandoffReady, + authHandoffUserId: handoffUserId, + seedState: seed.state, + seedPhase: seed.phase, + seedMode: seed.mode, + instanceId, + executionWorkspaceId, + companyId, + // A seed-recorded failure phase is more specific than anything this probe + // can infer, so it wins. + failurePhase: seed.failurePhase ?? (state === "ready" ? null : probeFailurePhase), + }; +} diff --git a/server/src/services/workspace-runtime-exposure.test.ts b/server/src/services/workspace-runtime-exposure.test.ts index 10c4f12a26b..d4dd1a35505 100644 --- a/server/src/services/workspace-runtime-exposure.test.ts +++ b/server/src/services/workspace-runtime-exposure.test.ts @@ -6,7 +6,10 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import type { BrokerClient, BrokerListenerRequest } from "./runtime-exposure/broker-client.js"; -import { diagnoseRuntimeListenerBinds } from "./runtime-exposure/loopback-listener.js"; +import { + diagnoseRuntimeListenerBinds, + readListenerBindFacts, +} from "./runtime-exposure/loopback-listener.js"; import { resetRuntimeServicesForTests, setWorkspaceRuntimeExposureDepsForTests, @@ -35,7 +38,9 @@ afterEach(async () => { }); function serviceCommand() { - return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((_,r)=>{r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`; + // Answers `/api/health` the way a real Paperclip dev runtime does: managed + // publication requires semantic health, not just a 200 (PAP-17572). + return `node -e 'const http=require("http");const p=Number(process.env.PORT);for(const q of [p,p+10000])http.createServer((rq,r)=>{if(rq.url==="/api/health"){r.setHeader("content-type","application/json");r.end(JSON.stringify({status:"ok"}));return}r.statusCode=200;r.end("ok")}).listen(q,"127.0.0.1");setInterval(()=>{},1000)'`; } /** @@ -70,8 +75,11 @@ const host = mode === "custom" ? (valueOf("--bind-host") ?? "127.0.0.1") : mode === "lan" ? "0.0.0.0" : "127.0.0.1"; const p = Number(process.env.PORT); +// Even a pre-exposure checkout answered /api/health semantically; these guests +// model bind behaviour, not health behaviour. +const health = (rq, r) => { if (rq.url === "/api/health") { r.setHeader("content-type", "application/json"); r.end(JSON.stringify({ status: "ok" })); return true; } return false; }; for (const q of [p, p + 10000]) { - http.createServer((_, r) => { r.statusCode = 200; r.end("ok"); }).listen(q, host); + http.createServer((rq, r) => { if (health(rq, r)) return; r.statusCode = 200; r.end("ok"); }).listen(q, host); } setInterval(() => {}, 1000); `; @@ -89,7 +97,8 @@ const argv = process.argv.slice(2); const at = argv.indexOf("--bind-host"); const host = at >= 0 ? argv[at + 1] : "127.0.0.1"; const p = Number(process.env.PORT); -http.createServer((_, r) => { r.statusCode = 200; r.end("ok"); }).listen(p, host); +const health = (rq, r) => { if (rq.url === "/api/health") { r.setHeader("content-type", "application/json"); r.end(JSON.stringify({ status: "ok" })); return true; } return false; }; +http.createServer((rq, r) => { if (health(rq, r)) return; r.statusCode = 200; r.end("ok"); }).listen(p, host); // No host argument: Vite's own HMR listener lands on the wildcard. http.createServer((_, r) => { r.statusCode = 426; r.end(); }).listen(p + 10000); setInterval(() => {}, 1000); @@ -99,8 +108,9 @@ setInterval(() => {}, 1000); const ALWAYS_WILDCARD_GUEST = ` import http from "node:http"; const p = Number(process.env.PORT); +const health = (rq, r) => { if (rq.url === "/api/health") { r.setHeader("content-type", "application/json"); r.end(JSON.stringify({ status: "ok" })); return true; } return false; }; for (const q of [p, p + 10000]) { - http.createServer((_, r) => { r.statusCode = 200; r.end("ok"); }).listen(q, "0.0.0.0"); + http.createServer((rq, r) => { if (health(rq, r)) return; r.statusCode = 200; r.end("ok"); }).listen(q, "0.0.0.0"); } setInterval(() => {}, 1000); `; @@ -110,6 +120,10 @@ beforeAll(async () => { 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 () => { @@ -155,6 +169,9 @@ function createBroker() { * "make exposure fixture honor occupied ports"). */ async function isLoopbackPortFree(port: number): Promise { + const facts = await readListenerBindFacts(port); + if (facts?.present) return false; + return await new Promise((resolve) => { const probe = net.createServer(); probe.unref(); @@ -461,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 @@ -517,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 () => { @@ -534,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); @@ -554,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-leases.ts b/server/src/services/workspace-runtime-leases.ts index 79e8fe9c6c8..eba79d341d6 100644 --- a/server/src/services/workspace-runtime-leases.ts +++ b/server/src/services/workspace-runtime-leases.ts @@ -28,7 +28,7 @@ const TERMINAL_HEARTBEAT_RUN_STATUSES = new Set([ ]); /** Runtime control actions that mutate the workspace and therefore need the lease. */ -export const LEASED_WORKSPACE_RUNTIME_ACTIONS: readonly string[] = ["start", "stop", "restart"]; +export const LEASED_WORKSPACE_RUNTIME_ACTIONS: readonly string[] = ["start", "stop", "restart", "repair"]; /** * Upper bound on how long a lease survives without the owner touching it. Recovery diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 0b04d2a4e5f..d1a4f7801c3 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -36,12 +36,23 @@ 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, isVerifiedWorktreeSeedManifest } from "../worktree-seed-manifest.js"; +import { + buildManagedWorkspaceGuestEnv, + logManagedWorkspaceReadinessRejection, + probeManagedWorkspaceHandoffSubjects, + probeManagedWorkspaceReadiness, + resolveManagedWorkspaceIdentity, + shouldBlockPublicationOnReadiness, + waitForManagedWorkspaceReadiness, +} from "./managed-workspace-identity.js"; import { createLocalServiceKey, findLocalServiceRegistryRecordByRuntimeServiceId, findAdoptableLocalService, isLocalServiceProcessOwnedBy, isLocalServiceProcessInWorkspace, + openLocalServiceLogFile, readLocalServiceProcessCwd, readLocalServicePortOwner, removeLocalServiceRegistryRecord, @@ -232,6 +243,8 @@ const runtimeServicesById = new Map(); const runtimeServicesByReuseKey = new Map(); const runtimeServiceLeasesByRun = new Map(); const runtimeProvisionByWorkspace = new Map>(); +const runtimeControlStartByOwner = new Map>(); +const runtimeReplacementClaimsByReuseKey = new Map(); const quarantinedRuntimeExposurePorts = new Set(); /** * Pair-atomic in-process claims for exposure allocations that have not bound a @@ -463,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()]) { @@ -472,11 +485,20 @@ 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(); runtimeServiceLeasesByRun.clear(); runtimeProvisionByWorkspace.clear(); + runtimeControlStartByOwner.clear(); + runtimeReplacementClaimsByReuseKey.clear(); quarantinedRuntimeExposurePorts.clear(); exposurePortPairClaims.clear(); workspaceRuntimeExposureDeps = defaultWorkspaceRuntimeExposureDeps(); @@ -2743,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, @@ -2857,7 +2893,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; @@ -2889,26 +2925,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, }; }, }); @@ -2934,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); @@ -3415,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; } @@ -4724,16 +4763,92 @@ function resolveRuntimeServiceHealthUrl( return url; } +type RuntimeServiceHealthProbeInput = { + db?: Db; + serviceName?: string | null; + command?: string | null; + provider?: string | null; + port?: number | null; + /** + * Workspace identity, when the caller knows it. Supplying all three upgrades + * the probe from "the port answered with status ok" to the full protected + * readiness contract, which is what stops a relocated port or a half-restored + * clone from masquerading as healthy (PAP-17572). + */ + cwd?: string | null; + executionWorkspaceId?: string | null; + companyId?: string | null; +}; + +/** + * Whether a managed workspace runtime satisfies the *user* readiness contract. + * + * Returns null when this service is not an identity-resolvable managed workspace + * runtime, so non-workspace services keep their existing behavior. + * + * For a workspace runtime this *replaces* the semantic transport check rather + * than adding to it. The probe reads the same `/api/health` response the legacy + * check read, so every legacy verdict is already implied: reaching + * `readiness_missing` means the response was `200` with `status: ok` (exactly + * what the legacy check asserted), and every other rejection means it was not. + * Stacking a second request on top would double the latency of every reuse + * decision for no extra information. + */ +async function probeManagedWorkspaceRuntimeReadiness( + healthUrl: string, + input: RuntimeServiceHealthProbeInput, +): Promise { + if (!isPaperclipDevRuntimeService(input)) return null; + const identity = resolveManagedWorkspaceIdentity({ + workspaceCwd: input.cwd ?? null, + executionWorkspaceId: input.executionWorkspaceId ?? null, + companyId: input.companyId ?? null, + }); + if (!identity) return null; + + const result = await probeManagedWorkspaceReadiness({ healthUrl, identity }); + const verified = !result.ok + ? result + : input.db + ? await probeManagedWorkspaceHandoffSubjects({ db: input.db, healthUrl, identity }) + : { + ok: false as const, + reason: "not_ready" as const, + readiness: result.readiness, + detail: "control-plane database is unavailable for board identity verification", + }; + if (verified.ok) return true; + logManagedWorkspaceReadinessRejection({ + executionWorkspaceId: identity.executionWorkspaceId, + healthUrl, + result: verified, + }); + // A guest that does not implement the readiness contract yet is not evidence of + // an unhealthy clone; it is only evidence that the contract cannot be checked. + return !shouldBlockPublicationOnReadiness(verified); +} + async function isRuntimeServiceUrlHealthy( url: string | null, - input?: { serviceName?: string | null; command?: string | null }, + input?: RuntimeServiceHealthProbeInput, ) { - if (!url) return true; - const healthUrl = resolveRuntimeServiceHealthUrl(url, input); + const localProbeUrl = input?.provider === "local_process" && input.port && isPaperclipDevRuntimeService(input) + ? `http://127.0.0.1:${input.port}` + : null; + const probeUrl = localProbeUrl ?? url; + if (!probeUrl) return true; + const healthUrl = resolveRuntimeServiceHealthUrl(probeUrl, input); if (!healthUrl) return false; + + const readiness = await probeManagedWorkspaceRuntimeReadiness(healthUrl, input ?? {}); + if (readiness !== null) return readiness; + try { const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) }); - return response.ok; + if (!response.ok) return false; + if (!isPaperclipDevRuntimeService(input ?? {})) return true; + const payload = await response.json().catch(() => null) as { status?: unknown } | null; + return payload?.status === "ok"; } catch { return false; } @@ -4962,6 +5077,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; @@ -4989,6 +5105,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; @@ -4999,22 +5158,32 @@ export function resolveRuntimeProvisionCommand(input: { if (input.workspace.strategy !== "git_worktree") return ""; const stateDir = path.join(input.workspace.cwd, ".paperclip"); - const pendingMarker = path.join(stateDir, "seed-pending"); - const completeMarker = path.join(stateDir, "seed-complete"); + const manifestPath = path.join(stateDir, "seed-manifest.json"); const provisionScript = path.join( input.workspace.baseCwd, "scripts", "provision-worktree-runtime.sh", ); - if ( - !existsSync(pendingMarker) - || existsSync(completeMarker) - || !existsSync(provisionScript) - ) { + const needsSeed = !existsSync(manifestPath) || !hasVerifiedWorktreeSeedManifest(manifestPath); + if (!needsSeed || !existsSync(provisionScript)) { 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) { @@ -5045,8 +5214,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, @@ -5059,14 +5229,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); @@ -5355,12 +5530,27 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P env[portEnvKey] = String(port); } + // Per-workspace handoff key, readiness token, and workspace id. Injected for + // the Paperclip dev runtime whether or not it is HTTPS-exposed, because the + // password-independent login handoff and the protected readiness probe are + // both needed for a plain-HTTP loopback workspace too (PAP-17572). + const managedWorkspaceIdentity = isPaperclipDevRuntimeService({ serviceName, command }) + ? resolveManagedWorkspaceIdentity({ + workspaceCwd: input.workspace.cwd, + executionWorkspaceId: input.executionWorkspaceId ?? null, + companyId: input.agent.companyId, + }) + : null; + if (managedWorkspaceIdentity) { + Object.assign(env, buildManagedWorkspaceGuestEnv(managedWorkspaceIdentity)); + } + if (exposureConfig) { // Paperclip dev-runtime-specific hardening. Other managed processes are // 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; @@ -5410,7 +5600,14 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P }); if (adoptedRecord) { const adoptedUrl = adoptedRecord.url ?? backendUrl; - if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName, command }))) { + if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { + db: input.db, + serviceName, + command, + cwd: input.workspace.cwd, + executionWorkspaceId: input.executionWorkspaceId ?? null, + companyId: input.agent.companyId, + }))) { await terminateLocalService(adoptedRecord); await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey); } else { @@ -5566,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; @@ -5581,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({ @@ -5682,10 +5887,53 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P ); } } + // Transport readiness only proves a listener answered. A managed workspace + // must additionally satisfy the protected readiness contract — own database, + // cloned rows, login handoff, and matching instance/workspace identity — + // before it may be published as running/healthy (PAP-17572). + if (managedWorkspaceIdentity) { + const publishHealthUrl = resolveRuntimeServiceHealthUrl( + record.port ? `http://127.0.0.1:${record.port}` : rewriteUrlHostToLoopback(record.url ?? backendUrl), + { serviceName, command }, + ); + if (!publishHealthUrl) { + throw new Error("Managed workspace readiness gate could not resolve a health URL"); + } + let gate = await waitForManagedWorkspaceReadiness({ + healthUrl: publishHealthUrl, + identity: managedWorkspaceIdentity, + }); + if (gate.ok) { + if (!record.db) { + throw new Error("Managed workspace readiness gate could not resolve the control-plane database"); + } + gate = await probeManagedWorkspaceHandoffSubjects({ + db: record.db, + healthUrl: publishHealthUrl, + identity: managedWorkspaceIdentity, + }); + } + if (!gate.ok) { + logManagedWorkspaceReadinessRejection({ + executionWorkspaceId: managedWorkspaceIdentity.executionWorkspaceId, + healthUrl: publishHealthUrl, + result: gate, + }); + if (shouldBlockPublicationOnReadiness(gate)) { + throw new Error( + `Workspace is not ready to publish (${gate.reason}${gate.detail ? `: ${gate.detail}` : ""})`, + ); + } + } + } record.status = "running"; 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, @@ -5693,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 }); @@ -5717,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()}` : ""}`, ); }); @@ -5904,6 +6160,25 @@ async function stopRuntimeService(serviceId: string) { const record = runtimeServicesById.get(serviceId); if (!record) return; clearIdleTimer(record); + // 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); + if (Number.isInteger(pid) && pid > 0) { + await terminateLocalService({ + pid, + processGroupId: record.processGroupId, + port: record.port, + }); + } + } record.status = "stopped"; record.healthStatus = "unknown"; record.lastUsedAt = new Date().toISOString(); @@ -5912,25 +6187,45 @@ async function stopRuntimeService(serviceId: string) { if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) { runtimeServicesByReuseKey.delete(record.reuseKey); } - await cleanupRecordExposure(record); - if (record.child && record.child.pid) { - await terminateLocalService({ - pid: record.child.pid, - processGroupId: record.processGroupId ?? record.child.pid, - }); - } else if (record.providerRef) { - const pid = Number.parseInt(record.providerRef, 10); - if (Number.isInteger(pid) && pid > 0) { - await terminateLocalService({ - pid, - processGroupId: record.processGroupId, - }); - } - } await removeLocalServiceRegistryRecord(record.serviceKey); await persistRuntimeServiceRecord(record.db, record); } +async function findHealthyRunningRuntimeService(reuseKey: string | null) { + const existingId = reuseKey ? runtimeServicesByReuseKey.get(reuseKey) : null; + const existing = existingId ? runtimeServicesById.get(existingId) : null; + if (!existing || existing.status !== "running") return null; + const healthInput = { + db: existing.db, + serviceName: existing.serviceName, + command: existing.command, + provider: existing.provider, + port: existing.port, + cwd: existing.cwd, + executionWorkspaceId: existing.executionWorkspaceId, + companyId: existing.companyId, + }; + let healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput); + if (!healthy) { + // A single timeout or connection reset is not enough evidence to destroy a + // shared runtime that active runs may still use. Confirm the failure after + // a short bounded delay before entering the destructive replacement path. + await delay(250); + healthy = await isRuntimeServiceUrlHealthy(existing.url, healthInput); + } + if (healthy) return existing; + if (existing.leaseRunIds.size > 0) { + existing.healthStatus = "unhealthy"; + if (reuseKey && runtimeServicesByReuseKey.get(reuseKey) === existing.id) { + runtimeServicesByReuseKey.delete(reuseKey); + } + await persistRuntimeServiceRecord(existing.db, existing); + return null; + } + await stopRuntimeService(existing.id); + return null; +} + async function markPersistedRuntimeServicesStoppedForExecutionWorkspace(input: { db: Db; executionWorkspaceId: string; @@ -6254,7 +6549,7 @@ async function isPersistedIsolatedExecutionWorkspace(input: { return row?.mode === "isolated_workspace"; } -export async function ensureRuntimeServicesForRun(input: { +type EnsureRuntimeServicesForRunInput = { db?: Db; runId: string; agent: ExecutionWorkspaceAgentRef; @@ -6265,7 +6560,11 @@ export async function ensureRuntimeServicesForRun(input: { adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; recorder?: WorkspaceOperationRecorder | null; -}): Promise { +}; + +async function ensureRuntimeServicesForRunInvocation( + input: EnsureRuntimeServicesForRunInput, +): Promise { const rawServices = selectRuntimeServiceEntries({ config: input.config, respectDesiredStates: true, @@ -6274,7 +6573,8 @@ export async function ensureRuntimeServicesForRun(input: { }); 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, @@ -6304,9 +6604,8 @@ export async function ensureRuntimeServicesForRun(input: { }).reuseKey; if (reuseKey) { - const existingId = runtimeServicesByReuseKey.get(reuseKey); - const existing = existingId ? runtimeServicesById.get(existingId) : null; - if (existing && existing.status === "running") { + const existing = await findHealthyRunningRuntimeService(reuseKey); + if (existing) { existing.leaseRunIds.add(input.runId); existing.lastUsedAt = new Date().toISOString(); existing.stoppedAt = null; @@ -6333,6 +6632,7 @@ export async function ensureRuntimeServicesForRun(input: { service, onLog: input.onLog, runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, recorder: input.recorder, provisionCoordinator, allowFixedPortFallback, @@ -6354,6 +6654,124 @@ export async function ensureRuntimeServicesForRun(input: { return refs; } +async function withRuntimeStartMutex(ownerKey: string, start: () => Promise): Promise { + const previous = runtimeControlStartByOwner.get(ownerKey) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const queued = previous.then(() => current); + runtimeControlStartByOwner.set(ownerKey, queued); + await previous; + try { + return await start(); + } finally { + release(); + if (runtimeControlStartByOwner.get(ownerKey) === queued) runtimeControlStartByOwner.delete(ownerKey); + } +} + +function resolveRuntimeStartMutexPlan(input: { + services: Array>; + workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; + issue: ExecutionWorkspaceIssueRef | null; + runId: string; + agent: ExecutionWorkspaceAgentRef; + adapterEnv: Record; +}) { + const fallbackOwnerId = input.executionWorkspaceId + ?? input.workspace.workspaceId + ?? path.resolve(input.workspace.cwd); + const replacementReuseKeys: string[] = []; + const keys = input.services.map((service) => { + const { scopeType, scopeId } = resolveServiceScopeId({ + service, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + issue: input.issue, + runId: input.runId, + agent: input.agent, + }); + const reuseKey = resolveRuntimeServiceReuseIdentity({ + service, + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType, + scopeId, + }).reuseKey; + // Converge all callers that can replace an existing shared runtime on its + // reuse identity. For an initial start, retain owner-level concurrency so + // the exposure allocator's in-flight pair claims remain authoritative. + if ( + reuseKey + && (runtimeServicesByReuseKey.has(reuseKey) || runtimeReplacementClaimsByReuseKey.has(reuseKey)) + ) { + runtimeReplacementClaimsByReuseKey.set( + reuseKey, + (runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 0) + 1, + ); + replacementReuseKeys.push(reuseKey); + return `reuse:${reuseKey}`; + } + return `${input.agent.companyId}:owner:${fallbackOwnerId}`; + }); + return { + ownerKeys: [...new Set(keys)].sort(), + replacementReuseKeys, + }; +} + +function releaseRuntimeReplacementClaims(reuseKeys: string[]) { + for (const reuseKey of reuseKeys) { + const next = (runtimeReplacementClaimsByReuseKey.get(reuseKey) ?? 1) - 1; + if (next <= 0) runtimeReplacementClaimsByReuseKey.delete(reuseKey); + else runtimeReplacementClaimsByReuseKey.set(reuseKey, next); + } +} + +async function withRuntimeStartMutexes( + ownerKeys: string[], + start: () => Promise, +): Promise { + const acquire = async (index: number): Promise => { + const ownerKey = ownerKeys[index]; + if (!ownerKey) return await start(); + return await withRuntimeStartMutex(ownerKey, () => acquire(index + 1)); + }; + return await acquire(0); +} + +export async function ensureRuntimeServicesForRun( + input: EnsureRuntimeServicesForRunInput, +): Promise { + const services = selectRuntimeServiceEntries({ + config: input.config, + respectDesiredStates: true, + defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "running", + serviceStates: readConfiguredServiceStates(input.config), + }); + const mutexPlan = resolveRuntimeStartMutexPlan({ + services, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + issue: input.issue, + runId: input.runId, + agent: input.agent, + adapterEnv: input.adapterEnv, + }); + try { + return await withRuntimeStartMutexes( + mutexPlan.ownerKeys, + () => ensureRuntimeServicesForRunInvocation(input), + ); + } finally { + releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys); + } +} + type StartRuntimeServicesForWorkspaceControlInput = { db?: Db; invocationId?: string; @@ -6385,6 +6803,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( deferReadiness?: boolean; allowFixedPortFallback?: boolean; runtimeProvisionCommand?: string; + runtimeProvisionKind?: RuntimeProvisionKind | null; provisionCoordinator?: RuntimeProvisionCoordinator; preparedProvisioning?: { service: Record; @@ -6417,9 +6836,8 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( }).reuseKey; if (reuseKey) { - const existingId = runtimeServicesByReuseKey.get(reuseKey); - const existing = existingId ? runtimeServicesById.get(existingId) : null; - if (existing && existing.status === "running") { + const existing = await findHealthyRunningRuntimeService(reuseKey); + if (existing) { const prepared = options?.preparedProvisioning; if (prepared?.service === service && prepared.record.id !== existing.id && persistenceDb) { await persistenceDb @@ -6452,6 +6870,7 @@ async function startRuntimeServicesForWorkspaceControlUnlocked( service, onLog: input.onLog, runtimeProvisionCommand: options?.runtimeProvisionCommand, + runtimeProvisionKind: options?.runtimeProvisionKind, recorder: input.recorder, provisionCoordinator: options?.provisionCoordinator, preparedProvisioningRecord: @@ -6543,7 +6962,7 @@ async function discardFailedDeferredRuntimeStart(db: Db, record: RuntimeServiceR await persistRuntimeServiceRecord(db, record); } -export async function startRuntimeServicesForWorkspaceControl( +async function startRuntimeServicesForWorkspaceControlInvocation( input: StartRuntimeServicesForWorkspaceControlInput, ): Promise { const rawServices = selectRuntimeServiceEntries({ @@ -6554,7 +6973,8 @@ export async function startRuntimeServicesForWorkspaceControl( 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); @@ -6573,7 +6993,11 @@ export async function startRuntimeServicesForWorkspaceControl( invocationId, input.db, input.db, - { runtimeProvisionCommand, provisionCoordinator }, + { + runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, + provisionCoordinator, + }, ); return batch.refs; } @@ -6608,9 +7032,8 @@ export async function startRuntimeServicesForWorkspaceControl( scopeType, scopeId, }).reuseKey; - const existingId = reuseKey ? runtimeServicesByReuseKey.get(reuseKey) : null; - const existing = existingId ? runtimeServicesById.get(existingId) : null; - if (existing?.status === "running") continue; + const existing = await findHealthyRunningRuntimeService(reuseKey); + if (existing) continue; const record = await prepareRuntimeProvisioning({ db: input.db, @@ -6625,6 +7048,7 @@ export async function startRuntimeServicesForWorkspaceControl( service, onLog: input.onLog, runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, recorder: input.recorder, provisionCoordinator, reuseKey, @@ -6653,6 +7077,7 @@ export async function startRuntimeServicesForWorkspaceControl( deferReadiness: true, allowFixedPortFallback, runtimeProvisionCommand, + runtimeProvisionKind: runtimeProvision.kind, provisionCoordinator, preparedProvisioning, }, @@ -6762,6 +7187,36 @@ export async function startRuntimeServicesForWorkspaceControl( } } +export async function startRuntimeServicesForWorkspaceControl( + input: StartRuntimeServicesForWorkspaceControlInput, +): Promise { + const services = selectRuntimeServiceEntries({ + config: input.config, + serviceIndex: input.serviceIndex, + respectDesiredStates: input.respectDesiredStates, + defaultDesiredState: readDesiredRuntimeState(input.config.desiredState) ?? "stopped", + serviceStates: readConfiguredServiceStates(input.config), + }); + const invocationId = input.invocationId ?? "workspace_control"; + const mutexPlan = resolveRuntimeStartMutexPlan({ + services, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + issue: input.issue, + runId: invocationId, + agent: input.actor, + adapterEnv: input.adapterEnv, + }); + try { + return await withRuntimeStartMutexes( + mutexPlan.ownerKeys, + () => startRuntimeServicesForWorkspaceControlInvocation(input), + ); + } finally { + releaseRuntimeReplacementClaims(mutexPlan.replacementReuseKeys); + } +} + export async function releaseRuntimeServicesForRun(runId: string) { const acquired = runtimeServiceLeasesByRun.get(runId) ?? []; runtimeServiceLeasesByRun.delete(runId); @@ -6773,7 +7228,14 @@ export async function releaseRuntimeServicesForRun(runId: string) { const stopType = asString(record.stopPolicy?.type, record.lifecycle === "ephemeral" ? "on_run_finish" : "manual"); await persistRuntimeServiceRecord(record.db, record); if (record.leaseRunIds.size === 0) { - if (record.lifecycle === "ephemeral" || stopType === "on_run_finish") { + const detachedUnhealthySharedRuntime = record.healthStatus === "unhealthy" + && Boolean(record.reuseKey) + && runtimeServicesByReuseKey.get(record.reuseKey!) !== record.id; + if ( + record.lifecycle === "ephemeral" + || stopType === "on_run_finish" + || detachedUnhealthySharedRuntime + ) { await stopRuntimeService(serviceId); continue; } @@ -7004,6 +7466,64 @@ async function buildPersistedRuntimeExposureIntentLookup(db: Db) { }; } +export async function refreshPersistedRuntimeServiceHealth(input: { + db: Db; + companyId: string; + executionWorkspaceId: string; + projectWorkspaceId?: string | null; +}) { + const ownershipCondition = input.projectWorkspaceId + ? or( + eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId), + and( + eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + ), + ) + : eq(workspaceRuntimeServices.executionWorkspaceId, input.executionWorkspaceId); + const rows = await input.db + .select({ + id: workspaceRuntimeServices.id, + serviceName: workspaceRuntimeServices.serviceName, + command: workspaceRuntimeServices.command, + provider: workspaceRuntimeServices.provider, + port: workspaceRuntimeServices.port, + url: workspaceRuntimeServices.url, + healthStatus: workspaceRuntimeServices.healthStatus, + cwd: workspaceRuntimeServices.cwd, + executionWorkspaceId: workspaceRuntimeServices.executionWorkspaceId, + companyId: workspaceRuntimeServices.companyId, + }) + .from(workspaceRuntimeServices) + .where(and( + eq(workspaceRuntimeServices.companyId, input.companyId), + eq(workspaceRuntimeServices.provider, "local_process"), + eq(workspaceRuntimeServices.status, "running"), + ownershipCondition, + )); + const results = await Promise.all(rows.map(async (row) => ({ + row, + healthStatus: await isRuntimeServiceUrlHealthy(row.url, { ...row, db: input.db }) + ? "healthy" as const + : "unhealthy" as const, + }))); + await Promise.all(results.map(async ({ row, healthStatus }) => { + const liveRecord = runtimeServicesById.get(row.id); + if (liveRecord) liveRecord.healthStatus = healthStatus; + if (row.healthStatus === healthStatus) return; + await input.db.update(workspaceRuntimeServices).set({ healthStatus, updatedAt: new Date() }).where(and( + eq(workspaceRuntimeServices.id, row.id), + eq(workspaceRuntimeServices.companyId, input.companyId), + eq(workspaceRuntimeServices.status, "running"), + )); + })); + return { + checked: results.length, + healthy: results.filter((result) => result.healthStatus === "healthy").length, + unhealthy: results.filter((result) => result.healthStatus === "unhealthy").length, + }; +} + export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { const rows = await db .select() @@ -7185,7 +7705,16 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { if ( backfillDecision.action === "reprovision" || !exposureHealthMatches - || !(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName: row.serviceName, command: row.command })) + || !(await isRuntimeServiceUrlHealthy(adoptedUrl, { + db, + serviceName: row.serviceName, + command: row.command, + provider: "local_process", + port: adoptedRecord.port ?? row.port, + cwd: row.cwd, + executionWorkspaceId: row.executionWorkspaceId ?? null, + companyId: row.companyId, + })) ) { if (backfillDecision.action === "reprovision") backfilled += 1; await terminateLocalService(adoptedRecord); 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); } } - diff --git a/server/src/worktree-seed-manifest.ts b/server/src/worktree-seed-manifest.ts new file mode 100644 index 00000000000..d1b281ed894 --- /dev/null +++ b/server/src/worktree-seed-manifest.ts @@ -0,0 +1,54 @@ +import { existsSync, readFileSync } from "node:fs"; + +const WORKTREE_SEED_MODES = new Set(["minimal", "full"]); + +type SeedDiagnostic = { + phase?: unknown; + status?: unknown; + at?: unknown; +}; + +/** + * A manifest is readiness evidence only when the complete validation phase was + * durably recorded. A bare `state: verified` is deliberately insufficient so + * truncated or hand-edited files cannot start a partially restored workspace. + */ +export function isVerifiedWorktreeSeedManifest(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const manifest = value as Record; + const source = manifest.source as Record | null; + const diagnostics = manifest.diagnostics as SeedDiagnostic[] | null; + return manifest.version === 2 + && manifest.state === "verified" + && manifest.phase === "complete" + && typeof source?.instanceId === "string" + && source.instanceId.length > 0 + && typeof source.configPath === "string" + && source.configPath.length > 0 + && WORKTREE_SEED_MODES.has(String(manifest.seedMode ?? "")) + && typeof manifest.snapshotAt === "string" + && manifest.snapshotAt.length > 0 + && typeof manifest.migrationRevision === "string" + && manifest.migrationRevision.length > 0 + && typeof manifest.targetInstanceId === "string" + && manifest.targetInstanceId.length > 0 + && typeof manifest.attemptId === "string" + && manifest.attemptId.length > 0 + && typeof manifest.startedAt === "string" + && typeof manifest.finishedAt === "string" + && Array.isArray(diagnostics) + && diagnostics.some((diagnostic) => ( + diagnostic?.phase === "complete" + && diagnostic.status === "succeeded" + && typeof diagnostic.at === "string" + )); +} + +export function hasVerifiedWorktreeSeedManifest(manifestPath: string): boolean { + if (!existsSync(manifestPath)) return false; + try { + return isVerifiedWorktreeSeedManifest(JSON.parse(readFileSync(manifestPath, "utf8"))); + } catch { + return false; + } +} diff --git a/server/vitest.config.ts b/server/vitest.config.ts index cd2fafdb89a..0356d4787b6 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", + include: ["src/**/*.test.ts"], // Each server suite boots + tears down its own embedded Postgres in // beforeAll/afterAll. Under the loaded serial shard (maxWorkers=1) the // graceful shutdown can occasionally cross vitest's default 10s hookTimeout, diff --git a/ui/package.json b/ui/package.json index 1e81e21011c..cb8f5d66073 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", @@ -62,24 +62,24 @@ "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.1", + "react-router-dom": "^7.18.2", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0" }, "devDependencies": { - "@storybook/addon-a11y": "10.5.4", - "@storybook/addon-docs": "10.5.0", - "@storybook/react-vite": "10.5.0", + "@storybook/addon-a11y": "10.5.8", + "@storybook/addon-docs": "10.5.8", + "@storybook/react-vite": "10.5.8", "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.20.1", "@types/react": "^19.2.17", "@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" 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/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/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index e6c075a86d1..05ee341c629 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -4,6 +4,7 @@ import type { ExecutionWorkspaceStatus, ExecutionWorkspaceCloseReadiness, WorkspaceOverviewResponse, + WorkspaceLoginHandoffTicketResponse, WorkspaceOperation, WorkspaceRuntimeControlTarget, } from "@paperclipai/shared"; @@ -114,6 +115,23 @@ export const executionWorkspacesApi = { `/execution-workspaces/${id}/runtime-commands/${action}`, sanitizeWorkspaceRuntimeControlTarget(target), ), + repair: (id: string) => + api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( + `/execution-workspaces/${id}/runtime-commands/repair`, + {}, + ), + /** + * Mint a single-use workspace login handoff (PAP-17572). + * + * The returned URL carries a short-lived ticket, so the caller must navigate to + * it rather than store or share it. The server answers the navigation with an + * HTTP redirect, which is what keeps the ticket out of browser history. + */ + requestLoginHandoff: (id: string, next?: string) => + api.post( + `/execution-workspaces/${id}/login-handoff`, + next ? { next } : {}, + ), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), /** * Reconcile a git-worktree branch divergence via the S4 (`PAP-1586`) op. 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/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index bcede8f1ddc..6d1027ba9f5 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -61,6 +61,38 @@ interface IssueRowProps { showDivider?: boolean; } +export function InboxArchiveButton({ + onArchive, + disabled, +}: { + onArchive: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + 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/components/WorkspaceAccessCard.test.tsx b/ui/src/components/WorkspaceAccessCard.test.tsx new file mode 100644 index 00000000000..9c93bca108d --- /dev/null +++ b/ui/src/components/WorkspaceAccessCard.test.tsx @@ -0,0 +1,194 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceAccessCard } from "./WorkspaceAccessCard"; +import type { WorkspaceAccessState } from "../lib/workspace-access-state"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function act(callback: () => void) { + flushSync(callback); +} + +function accessState(overrides: Partial = {}): WorkspaceAccessState { + 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: true, + ...overrides, + }; +} + +describe("WorkspaceAccessCard", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.innerHTML = ""; + }); + + function renderCard(props: Partial[0]> = {}) { + const handlers = { + onOpen: vi.fn(), + onStart: vi.fn(), + onRepair: vi.fn(), + onViewLogs: vi.fn(), + }; + const root = createRoot(container); + act(() => { + root.render(); + }); + const button = container.querySelector("button"); + return { ...handlers, root, button }; + } + + function findButton(label: string) { + return Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.trim() === label, + ); + } + + it("renders the ready state and opens without asking for a password", () => { + const { onOpen, root } = renderCard(); + const card = container.querySelector("[data-testid='workspace-access-card']"); + expect(card?.getAttribute("data-state")).toBe("ready"); + expect(container.textContent).toContain("Ready"); + expect(container.textContent).toContain("single-use login handoff"); + + const button = findButton("Open workspace"); + expect(button).toBeDefined(); + act(() => button!.click()); + expect(onOpen).toHaveBeenCalledTimes(1); + + act(() => root.unmount()); + }); + + it("keeps a superseded repair failure visible without hiding the open action", () => { + const { onOpen, onViewLogs, root } = renderCard({ + access: accessState({ + secondaryNotice: { + title: "Repair failed", + description: "The repair stopped during managed_restart. The pre-repair backup was kept.", + action: { kind: "view_logs", label: "View repair log" }, + }, + }), + }); + + const openButton = findButton("Open workspace"); + const logButton = findButton("View repair log"); + expect(openButton).toBeDefined(); + expect(logButton).toBeDefined(); + expect(container.querySelector("[data-testid='workspace-access-secondary-notice']")?.textContent) + .toContain("pre-repair backup was kept"); + + act(() => openButton!.click()); + act(() => logButton!.click()); + expect(onOpen).toHaveBeenCalledTimes(1); + expect(onViewLogs).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("labels the credential fallback accurately when no handoff is available", () => { + const { root } = renderCard({ + access: accessState({ + handoffAvailable: false, + description: "Opening the board will ask for the credentials captured in this snapshot.", + }), + }); + expect(container.textContent).toContain("snapshot-local credentials captured"); + expect(container.textContent).not.toContain("single-use login handoff"); + act(() => root.unmount()); + }); + + it("gives provisioning, degraded, and failed states their own safe action", () => { + const cases = [ + { + access: accessState({ + state: "provisioning" as const, + title: "Provisioning database", + action: { kind: "start" as const, label: "Start workspace" }, + }), + label: "Start workspace", + handlerKey: "onStart" as const, + }, + { + access: accessState({ + state: "degraded" as const, + title: "Workspace is degraded", + action: { kind: "repair" as const, label: "Repair workspace" }, + }), + label: "Repair workspace", + handlerKey: "onRepair" as const, + }, + { + access: accessState({ + state: "failed" as const, + title: "Repair failed", + action: { kind: "view_logs" as const, label: "View repair log" }, + }), + label: "View repair log", + handlerKey: "onViewLogs" as const, + }, + ]; + + for (const testCase of cases) { + const rendered = renderCard({ access: testCase.access }); + expect(container.textContent).toContain(testCase.access.title); + const button = findButton(testCase.label); + expect(button).toBeDefined(); + expect(button!.disabled).toBe(false); + act(() => button!.click()); + expect(rendered[testCase.handlerKey]).toHaveBeenCalledTimes(1); + act(() => rendered.root.unmount()); + container.innerHTML = ""; + } + }); + + it("offers nothing clickable while a repair is already running", () => { + const { root } = renderCard({ + access: accessState({ + state: "repairing", + title: "Repairing workspace database", + action: { kind: "wait", label: "Repair in progress" }, + }), + }); + expect(findButton("Repair in progress")?.disabled).toBe(true); + expect( + container.querySelector("[data-testid='workspace-access-badge']")?.textContent, + ).toContain("Repairing"); + act(() => root.unmount()); + }); + + it("always names the state and the cause instead of a bare failure", () => { + const { root } = renderCard({ + access: accessState({ + state: "failed", + title: "Database provisioning failed", + description: "The clone failed during restore. Repairing replaces only the isolated database.", + action: { kind: "repair", label: "Repair workspace" }, + }), + errorMessage: "Failed to open the workspace.", + }); + expect(container.textContent).toContain("Database provisioning failed"); + expect(container.textContent).toContain("failed during restore"); + expect( + container.querySelector("[data-testid='workspace-access-error']")?.textContent, + ).toContain("Failed to open the workspace."); + act(() => root.unmount()); + }); + + it("disables the action while the caller reports it busy", () => { + const { root } = renderCard({ isBusy: true }); + expect(findButton("Open workspace")?.disabled).toBe(true); + act(() => root.unmount()); + }); +}); diff --git a/ui/src/components/WorkspaceAccessCard.tsx b/ui/src/components/WorkspaceAccessCard.tsx new file mode 100644 index 00000000000..6373d4a69c5 --- /dev/null +++ b/ui/src/components/WorkspaceAccessCard.tsx @@ -0,0 +1,149 @@ +import { ExternalLink, Loader2, Play, ScrollText, Wrench } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "../lib/utils"; +import type { WorkspaceAccessState } from "../lib/workspace-access-state"; + +/** + * Workspace access surface (PAP-17572). + * + * One card per workspace that names the state, the cause, and the single safe + * next action. It replaces the previous behavior where a cloned workspace whose + * database was missing still rendered a plain "Open" link and answered with + * "invalid email or password". + */ + +/** + * Badge tones come from the semantic status token layer, so a theme change moves + * these states with every other status surface. `ready` reuses the "done" tone + * and `degraded` the "todo" (amber) tone; the icon variants are the contrast- + * corrected pair the token layer already tunes per mode. + */ +const STATE_BADGE_CLASSES: Record = { + provisioning: "border-border text-muted-foreground", + validating: "border-border text-muted-foreground", + ready: "border-(--status-task-done) text-(--status-task-icon-done)", + degraded: "border-(--status-task-todo) text-(--status-task-icon-todo)", + repairing: "border-border text-muted-foreground", + failed: "border-destructive/50 text-destructive", +}; + +const STATE_LABELS: Record = { + provisioning: "Provisioning", + validating: "Validating clone", + ready: "Ready", + degraded: "Degraded", + repairing: "Repairing", + failed: "Failed", +}; + +const ACTION_ICONS = { + open: ExternalLink, + start: Play, + repair: Wrench, + view_logs: ScrollText, + wait: Loader2, +} as const; + +export function WorkspaceAccessCard({ + access, + isBusy, + onOpen, + onStart, + onRepair, + onViewLogs, + errorMessage, +}: { + access: WorkspaceAccessState; + isBusy?: boolean; + onOpen: () => void; + onStart: () => void; + onRepair: () => void; + onViewLogs: () => void; + errorMessage?: string | null; +}) { + const Icon = ACTION_ICONS[access.action.kind]; + const isWaiting = access.action.kind === "wait"; + const handlers: Record void> = { + open: onOpen, + start: onStart, + repair: onRepair, + view_logs: onViewLogs, + wait: () => undefined, + }; + const SecondaryNoticeIcon = access.secondaryNotice + ? ACTION_ICONS[access.secondaryNotice.action.kind] + : null; + + return ( + + +
+ {access.title} + + {(access.state === "repairing" || access.state === "provisioning") && ( + + )} + {STATE_LABELS[access.state]} + +
+ {access.description} +
+ +
+ + {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..ffd60795bff --- /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_seed", + 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..7925008669a --- /dev/null +++ b/ui/src/lib/workspace-access-state.ts @@ -0,0 +1,272 @@ +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_seed") + ?? 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/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/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 + - ) : 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}