From 15eac43b43f78cfe890823378a00824828e272d9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sun, 3 May 2026 11:30:48 -0500 Subject: [PATCH 01/27] [codex] Retry max-turn exhausted heartbeats (#5096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for autonomous companies, and heartbeat execution is the control-plane loop that keeps assigned work moving. > - Max-turn exhaustion is a recoverable local-adapter stop condition for Claude and Gemini agents when a run needs another heartbeat to continue safely. > - The previous behavior could leave max-turn continuation details hard to inspect, and duplicate/stale continuation wakes could keep running after issue state changed. > - The adapter layer also needed to avoid trusting arbitrary stdout/stderr text as scheduler control metadata. > - This pull request adds bounded max-turn continuation scheduling, visible retry state, structured stop metadata handling, and stale/duplicate continuation guards. > - The benefit is safer automatic continuation after max-turn stops, clearer operator visibility, and fewer duplicate or stale agent runs. ## What Changed - Replaces closed PR #4952, whose head repository was deleted. - Rebases the recovered max-turn continuation branch onto current `paperclipai/paperclip:master`. - Adds max-turn continuation scheduling and retry-state plumbing for heartbeat runs. - Adds stale/duplicate continuation suppression when issue status, ownership, or execution locks change. - Normalizes Claude/Gemini max-turn detection around structured stop metadata instead of unstructured stdout/stderr text. - Surfaces max-turn continuation settings and retry visibility in the board UI. - Adds focused server, adapter, and UI tests for max-turn stop metadata, retry scheduling, stale queued-run invalidation, adapter parsing/execution, run ledger display, and agent config patching. ## Verification - `pnpm install --no-frozen-lockfile` to refresh local dependencies after rebasing onto current `master`. - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/claude-local-adapter.test.ts server/src/__tests__/claude-local-execute.test.ts server/src/__tests__/gemini-local-adapter.test.ts server/src/__tests__/gemini-local-execute.test.ts server/src/__tests__/heartbeat-retry-scheduling.test.ts server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts server/src/services/heartbeat-stop-metadata.test.ts ui/src/components/IssueRunLedger.test.tsx ui/src/lib/agent-config-patch.test.ts ui/src/lib/runRetryState.test.ts --testTimeout=20000` - `pnpm --filter @paperclipai/adapter-claude-local typecheck && pnpm --filter @paperclipai/adapter-gemini-local typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` - UI screenshot note: the UI changes are limited to config/ledger state rendering rather than layout changes; component/unit coverage above verifies the rendered behavior. ## Risks - Medium behavior risk: heartbeat retry gating now suppresses max-turn continuations when issue state or execution locks drift, so any callers that relied on stale continuations running will now see cancellation instead. - Low adapter risk: Claude/Gemini unstructured text no longer triggers max-turn scheduler metadata, so only structured stop signals and Gemini exit code 53 are trusted. - No database migrations. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent, GPT-5-class model, tool-enabled local repository editing and command execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots (not applicable: state/default rendering only; covered by component/unit tests) - [x] I have updated relevant documentation to reflect my changes (not applicable: no user-facing command or docs contract changed) - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../claude-local/src/server/execute.ts | 4 + .../adapters/claude-local/src/server/parse.ts | 16 +- .../gemini-local/src/server/execute.ts | 46 +- .../adapters/gemini-local/src/server/parse.ts | 17 +- .../__tests__/claude-local-adapter.test.ts | 18 + .../__tests__/claude-local-execute.test.ts | 131 ++++ .../__tests__/gemini-local-adapter.test.ts | 27 +- .../__tests__/gemini-local-execute.test.ts | 167 ++++ .../heartbeat-retry-scheduling.test.ts | 504 +++++++++++- ...heartbeat-stale-queue-invalidation.test.ts | 156 +++- .../services/heartbeat-stop-metadata.test.ts | 34 + .../src/services/heartbeat-stop-metadata.ts | 12 +- server/src/services/heartbeat.ts | 719 +++++++++++++++--- ui/src/components/AgentConfigForm.tsx | 70 +- ui/src/components/IssueRunLedger.test.tsx | 38 + ui/src/components/IssueRunLedger.tsx | 1 + ui/src/components/agent-config-primitives.tsx | 3 + ui/src/lib/agent-config-patch.test.ts | 29 + ui/src/lib/runRetryState.test.ts | 17 + ui/src/lib/runRetryState.ts | 12 +- ui/src/lib/utils.ts | 14 + 21 files changed, 1915 insertions(+), 120 deletions(-) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index f047383a19e..e9ef11e2917 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -777,6 +777,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { ...parsed, + ...(failed && clearSessionForMaxTurns ? { stopReason: "max_turns_exhausted" } : {}), ...(transientUpstream ? { errorFamily: "transient_upstream" } : {}), ...(transientRetryNotBefore ? { retryNotBefore: transientRetryNotBefore.toISOString() } : {}), ...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}), diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index 4591aaad02c..f645c4f27aa 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -170,11 +170,19 @@ export function isClaudeMaxTurnsResult(parsed: Record | null | const subtype = asString(parsed.subtype, "").trim().toLowerCase(); if (subtype === "error_max_turns") return true; - const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase(); - if (stopReason === "max_turns") return true; + const structuredStopReasons = [ + parsed.stop_reason, + parsed.stopReason, + parsed.error_code, + parsed.errorCode, + ].map((value) => asString(value, "").trim().toLowerCase()); - const resultText = asString(parsed.result, "").trim(); - return /max(?:imum)?\s+turns?/i.test(resultText); + return structuredStopReasons.some((reason) => + reason === "max_turns" || + reason === "max_turns_exhausted" || + reason === "turn_limit" || + reason === "turn_limit_exhausted", + ); } export function isClaudeUnknownSessionError(parsed: Record): boolean { diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 026da5fa231..b9bb581cc23 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -531,7 +531,21 @@ export async function execute(ctx: AdapterExecutionContext): Promise) : null; - const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const structuredFailure = attempt.parsed.resultEvent - ? describeGeminiFailure(attempt.parsed.resultEvent) - : null; - const fallbackErrorMessage = - parsedError || - structuredFailure || - stderrLine || - `Gemini exited with code ${attempt.proc.exitCode ?? -1}`; + const resultJson: Record = { + ...(attempt.parsed.resultEvent ?? { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }), + ...(failed && clearSessionForTurnLimit ? { stopReason: "max_turns_exhausted" } : {}), + }; return { exitCode: attempt.proc.exitCode, signal: attempt.proc.signal, timedOut: false, - errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage, - errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null, + errorMessage: failed ? fallbackErrorMessage : null, + errorCode: failed && authMeta.requiresAuth + ? "gemini_auth_required" + : failed && clearSessionForTurnLimit + ? "max_turns_exhausted" + : null, usage: attempt.parsed.usage, sessionId: resolvedSessionId, sessionParams: resolvedSessionParams, @@ -577,10 +592,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise asString(value, "").trim().toLowerCase()); - const error = asString(parsed.error, "").trim(); - return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error); + return structuredStopReasons.some((reason) => + reason === "turn_limit" || + reason === "max_turns" || + reason === "max_turns_exhausted" || + reason === "turn_limit_exhausted", + ); } diff --git a/server/src/__tests__/claude-local-adapter.test.ts b/server/src/__tests__/claude-local-adapter.test.ts index d2cb8665ba5..f53c06f6386 100644 --- a/server/src/__tests__/claude-local-adapter.test.ts +++ b/server/src/__tests__/claude-local-adapter.test.ts @@ -21,6 +21,15 @@ describe("claude_local max-turn detection", () => { ).toBe(true); }); + it("checks every structured stop field for max-turn exhaustion", () => { + expect( + isClaudeMaxTurnsResult({ + stop_reason: "end_turn", + stopReason: "max_turns_exhausted", + }), + ).toBe(true); + }); + it("returns false for non-max-turn results", () => { expect( isClaudeMaxTurnsResult({ @@ -29,6 +38,15 @@ describe("claude_local max-turn detection", () => { }), ).toBe(false); }); + + it("does not detect max-turn exhaustion from unstructured result text", () => { + expect( + isClaudeMaxTurnsResult({ + subtype: "error", + result: "Tool output said: Maximum turns reached.", + }), + ).toBe(false); + }); }); describe("claude_local ui stdout parser", () => { diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index cad32753068..c34a576a86c 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -19,6 +19,24 @@ process.exit(${exit}); await fs.chmod(commandPath, 0o755); } +async function writeTextFailingClaudeCommand( + commandPath: string, + options: { stdout?: string; stderr?: string; exitCode?: number }, +): Promise { + const exit = options.exitCode ?? 1; + const script = `#!/usr/bin/env node +if (${JSON.stringify(options.stdout ?? "")}) { + process.stdout.write(${JSON.stringify(options.stdout ?? "")}); +} +if (${JSON.stringify(options.stderr ?? "")}) { + process.stderr.write(${JSON.stringify(options.stderr ?? "")}); +} +process.exit(${exit}); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + async function writeFakeClaudeCommand(commandPath: string): Promise { const script = `#!/usr/bin/env node const fs = require("node:fs"); @@ -372,6 +390,119 @@ describe("claude execute", () => { } }); + it("normalizes max-turn exhaustion into scheduler stop metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turns-")); + const resultEvent = { + type: "result", + subtype: "error_max_turns", + session_id: "claude-session-1", + is_error: true, + result: "Maximum turns reached.", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + }; + const { workspace, commandPath, restore } = await setupExecuteEnv(root, { + commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }), + }); + + try { + const result = await execute({ + runId: "run-max-turns", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + promptTemplate: "Do work.", + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("max_turns_exhausted"); + expect(result.errorFamily).toBeNull(); + expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" }); + expect(result.clearSession).toBe(true); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("does not normalize unstructured max-turn text into scheduler stop metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-text-")); + const resultEvent = { + type: "result", + subtype: "error", + session_id: "claude-session-1", + is_error: true, + result: "Tool output said: Maximum turns reached.", + }; + const { workspace, commandPath, restore } = await setupExecuteEnv(root, { + commandWriter: (commandPath) => writeFailingClaudeCommand(commandPath, { resultEvent }), + }); + + try { + const result = await execute({ + runId: "run-max-turns-text", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + promptTemplate: "Do work.", + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).not.toBe("max_turns_exhausted"); + expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted"); + expect(result.clearSession).toBe(false); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("does not normalize fallback stdout/stderr max-turn text into scheduler stop metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-max-turn-fallback-")); + const { workspace, commandPath, restore } = await setupExecuteEnv(root, { + commandWriter: (commandPath) => + writeTextFailingClaudeCommand(commandPath, { + stdout: "attacker-controlled tool output: max turns exhausted\n", + stderr: "Maximum turns reached.\n", + }), + }); + + try { + const result = await execute({ + runId: "run-max-turns-fallback-text", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + promptTemplate: "Do work.", + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).not.toBe("max_turns_exhausted"); + expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted"); + expect(result.clearSession).toBe(false); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/gemini-local-adapter.test.ts b/server/src/__tests__/gemini-local-adapter.test.ts index 5c36f6c32f4..3c22b01c8c5 100644 --- a/server/src/__tests__/gemini-local-adapter.test.ts +++ b/server/src/__tests__/gemini-local-adapter.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { isGeminiUnknownSessionError, parseGeminiJsonl } from "@paperclipai/adapter-gemini-local/server"; +import { + isGeminiTurnLimitResult, + isGeminiUnknownSessionError, + parseGeminiJsonl, +} from "@paperclipai/adapter-gemini-local/server"; import { parseGeminiStdoutLine } from "@paperclipai/adapter-gemini-local/ui"; import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; @@ -79,6 +83,27 @@ describe("gemini_local stale session detection", () => { }); }); +describe("gemini_local turn-limit detection", () => { + it("detects structured turn-limit signals and exit code 53", () => { + expect(isGeminiTurnLimitResult({ status: "turn_limit" })).toBe(true); + expect(isGeminiTurnLimitResult({ stopReason: "max_turns_exhausted" })).toBe(true); + expect(isGeminiTurnLimitResult(null, 53)).toBe(true); + }); + + it("checks every structured stop field for turn-limit exhaustion", () => { + expect( + isGeminiTurnLimitResult({ + status: "success", + stopReason: "turn_limit_exhausted", + }), + ).toBe(true); + }); + + it("does not detect turn-limit exhaustion from unstructured error text", () => { + expect(isGeminiTurnLimitResult({ error: "max_turns reached" })).toBe(false); + }); +}); + describe("gemini_local ui stdout parser", () => { it("parses assistant, thinking, and result events", () => { const ts = "2026-03-08T00:00:00.000Z"; diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 93a4aadd299..98381d8b8de 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -39,6 +39,35 @@ console.log(JSON.stringify({ await fs.chmod(commandPath, 0o755); } +async function writeFailingGeminiCommand( + commandPath: string, + options: { + stdoutLines?: Array>; + stdout?: string; + stderr?: string; + exitCode?: number; + }, +): Promise { + const stdoutLines = options.stdoutLines ?? []; + const stdout = options.stdout ?? ""; + const stderr = options.stderr ?? ""; + const exit = options.exitCode ?? 1; + const script = `#!/usr/bin/env node +for (const line of ${JSON.stringify(stdoutLines.map((line) => JSON.stringify(line)))}) { + console.log(line); +} +if (${JSON.stringify(stdout)}) { + process.stdout.write(${JSON.stringify(stdout)}); +} +if (${JSON.stringify(stderr)}) { + console.error(${JSON.stringify(stderr)}); +} +process.exit(${exit}); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + type CapturePayload = { argv: string[]; paperclipEnvKeys: string[]; @@ -169,6 +198,144 @@ describe("gemini execute", () => { } }); + it("normalizes turn-limit exhaustion into scheduler stop metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turns-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + await fs.mkdir(workspace, { recursive: true }); + await writeFailingGeminiCommand(commandPath, { + stdoutLines: [ + { + type: "result", + subtype: "error", + session_id: "gemini-session-1", + status: "turn_limit", + error: "Turn limit reached.", + }, + ], + }); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-turn-limit", + agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + }, + context: {}, + authToken: "t", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("max_turns_exhausted"); + expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" }); + expect(result.clearSession).toBe(true); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("normalizes Gemini exit code 53 as max-turn exhaustion", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-exit-53-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + await fs.mkdir(workspace, { recursive: true }); + await writeFailingGeminiCommand(commandPath, { + stderr: "Gemini stopped because the max turns limit was reached.", + exitCode: 53, + }); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-exit-53", + agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + }, + context: {}, + authToken: "t", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(53); + expect(result.errorCode).toBe("max_turns_exhausted"); + expect(result.resultJson).toMatchObject({ stopReason: "max_turns_exhausted" }); + expect(result.clearSession).toBe(true); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("does not normalize unstructured turn-limit text into scheduler stop metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-max-turn-text-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + await fs.mkdir(workspace, { recursive: true }); + await writeFailingGeminiCommand(commandPath, { + stdoutLines: [ + { + type: "result", + subtype: "error", + session_id: "gemini-session-1", + error: "Tool output said: maximum turns reached.", + }, + ], + stdout: "attacker-controlled transcript mentions turn limit reached\n", + stderr: "Gemini stopped because the max turns limit was reached.", + }); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-turn-limit-text", + agent: { id: "a1", companyId: "c1", name: "G", adapterType: "gemini_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + }, + context: {}, + authToken: "t", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).not.toBe("max_turns_exhausted"); + expect(result.resultJson?.stopReason).not.toBe("max_turns_exhausted"); + expect(result.clearSession).toBe(false); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-resume-wake-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index 9999c05e523..39b4d8987d9 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -1,15 +1,17 @@ import { randomUUID } from "node:crypto"; -import { eq, sql } from "drizzle-orm"; +import { and, eq, sql } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, agentRuntimeState, agentWakeupRequests, + budgetPolicies, companies, createDb, environmentLeases, heartbeatRunEvents, heartbeatRuns, + issueRelations, issues, } from "@paperclipai/db"; import { @@ -18,6 +20,8 @@ import { } from "./helpers/embedded-postgres.js"; import { BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS, + MAX_TURN_CONTINUATION_RETRY_REASON, + MAX_TURN_CONTINUATION_WAKE_REASON, heartbeatService, } from "../services/heartbeat.ts"; @@ -44,10 +48,12 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { afterEach(async () => { await db.delete(heartbeatRunEvents); await db.delete(environmentLeases); + await db.delete(issueRelations); await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); + await db.delete(budgetPolicies); await db.delete(agents); await db.delete(companies); }); @@ -124,6 +130,92 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { }); } + async function seedMaxTurnFixture(input?: { + companyId?: string; + agentId?: string; + issueId?: string; + runId?: string; + now?: Date; + scheduledRetryAttempt?: number; + runtimeConfig?: Record; + issueStatus?: string; + }) { + const companyId = input?.companyId ?? randomUUID(); + const agentId = input?.agentId ?? randomUUID(); + const issueId = input?.issueId ?? randomUUID(); + const runId = input?.runId ?? randomUUID(); + const now = input?.now ?? new Date("2026-04-20T12:00:00.000Z"); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "ClaudeCoder", + role: "engineer", + status: "active", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: input?.runtimeConfig ?? { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + maxTurnContinuation: { + enabled: true, + maxAttempts: 2, + delayMs: 1_000, + }, + }, + }, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "failed", + error: "Maximum turns reached", + errorCode: "adapter_failed", + finishedAt: now, + scheduledRetryAttempt: input?.scheduledRetryAttempt ?? 0, + scheduledRetryReason: input?.scheduledRetryAttempt ? MAX_TURN_CONTINUATION_RETRY_REASON : null, + resultJson: { + stopReason: "max_turns_exhausted", + }, + contextSnapshot: { + issueId, + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Continue after max turns", + status: input?.issueStatus ?? "in_progress", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: runId, + executionAgentNameKey: "claudecoder", + executionLockedAt: now, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + return { companyId, agentId, issueId, runId, now }; + } + it("schedules a retry with durable metadata and only promotes it when due", async () => { const companyId = randomUUID(); const agentId = randomUUID(); @@ -218,6 +310,416 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { expect(promotedRun?.status).toBe("queued"); }); + it("schedules max-turn continuations with distinct retry metadata", async () => { + const { runId, now } = await seedMaxTurnFixture(); + + const scheduled = await heartbeat.scheduleBoundedRetry(runId, { + now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }); + + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + expect(scheduled.attempt).toBe(1); + expect(scheduled.dueAt.toISOString()).toBe(new Date(now.getTime() + 1_000).toISOString()); + + const retryRun = await db + .select({ + retryOfRunId: heartbeatRuns.retryOfRunId, + status: heartbeatRuns.status, + scheduledRetryAttempt: heartbeatRuns.scheduledRetryAttempt, + scheduledRetryReason: heartbeatRuns.scheduledRetryReason, + contextSnapshot: heartbeatRuns.contextSnapshot, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + + expect(retryRun).toMatchObject({ + retryOfRunId: runId, + status: "scheduled_retry", + scheduledRetryAttempt: 1, + scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + }); + expect((retryRun?.contextSnapshot as Record | null)?.wakeReason).toBe( + MAX_TURN_CONTINUATION_WAKE_REASON, + ); + expect((retryRun?.contextSnapshot as Record | null)?.codexTransientFallbackMode ?? null).toBeNull(); + + const wakeupRequest = await db + .select({ reason: agentWakeupRequests.reason, payload: agentWakeupRequests.payload }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? "")) + .then((rows) => rows[0] ?? null); + expect(wakeupRequest?.reason).toBe(MAX_TURN_CONTINUATION_WAKE_REASON); + expect(wakeupRequest?.payload).toMatchObject({ + retryOfRunId: runId, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + scheduledRetryAttempt: 1, + }); + }); + + it("coalesces duplicate max-turn continuation schedules for the same source run and attempt", async () => { + const { issueId, runId, now } = await seedMaxTurnFixture(); + const retryOptions = { + now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }; + + const [first, second] = await Promise.all([ + heartbeat.scheduleBoundedRetry(runId, retryOptions), + heartbeat.scheduleBoundedRetry(runId, retryOptions), + ]); + + expect(first.outcome).toBe("scheduled"); + expect(second.outcome).toBe("scheduled"); + if (first.outcome !== "scheduled" || second.outcome !== "scheduled") return; + + expect(new Set([first.run.id, second.run.id]).size).toBe(1); + + const retryRuns = await db + .select({ + id: heartbeatRuns.id, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.retryOfRunId, runId), + eq(heartbeatRuns.scheduledRetryReason, MAX_TURN_CONTINUATION_RETRY_REASON), + eq(heartbeatRuns.scheduledRetryAttempt, 1), + ), + ); + expect(retryRuns).toHaveLength(1); + + const wakeups = await db + .select({ + id: agentWakeupRequests.id, + coalescedCount: agentWakeupRequests.coalescedCount, + idempotencyKey: agentWakeupRequests.idempotencyKey, + }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.reason, MAX_TURN_CONTINUATION_WAKE_REASON)); + expect(wakeups).toHaveLength(1); + expect(wakeups[0]).toMatchObject({ + id: retryRuns[0]?.wakeupRequestId, + coalescedCount: 1, + }); + expect(wakeups[0]?.idempotencyKey).toContain(`:${issueId}:${runId}:1`); + + const issue = await db + .select({ executionRunId: issues.executionRunId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.executionRunId).toBe(retryRuns[0]?.id); + }); + + it("does not promote a duplicate max-turn continuation that does not own the issue lock", async () => { + const { companyId, agentId, issueId, runId, now } = await seedMaxTurnFixture(); + + const scheduled = await heartbeat.scheduleBoundedRetry(runId, { + now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }); + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + + const duplicateWakeupId = randomUUID(); + const duplicateRunId = randomUUID(); + await db.insert(agentWakeupRequests).values({ + id: duplicateWakeupId, + companyId, + agentId, + source: "automation", + triggerDetail: "system", + reason: MAX_TURN_CONTINUATION_WAKE_REASON, + payload: { + issueId, + retryOfRunId: runId, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + scheduledRetryAttempt: 1, + }, + status: "queued", + requestedByActorType: "system", + }); + await db.insert(heartbeatRuns).values({ + id: duplicateRunId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "scheduled_retry", + wakeupRequestId: duplicateWakeupId, + retryOfRunId: runId, + scheduledRetryAt: scheduled.dueAt, + scheduledRetryAttempt: 1, + scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + contextSnapshot: { + issueId, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + }, + }); + await db + .update(agentWakeupRequests) + .set({ runId: duplicateRunId }) + .where(eq(agentWakeupRequests.id, duplicateWakeupId)); + + const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt); + expect(promotion).toEqual({ promoted: 1, runIds: [scheduled.run.id] }); + + const duplicate = await db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, duplicateRunId)) + .then((rows) => rows[0] ?? null); + expect(duplicate).toEqual({ + status: "cancelled", + errorCode: "issue_execution_lock_changed", + }); + + const duplicateWakeup = await db + .select({ status: agentWakeupRequests.status }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, duplicateWakeupId)) + .then((rows) => rows[0] ?? null); + expect(duplicateWakeup?.status).toBe("cancelled"); + }); + + it.each(["blocked", "todo", "backlog"] as const)( + "does not schedule a max-turn continuation when the issue is already %s", + async (issueStatus) => { + const { issueId, runId, now } = await seedMaxTurnFixture({ issueStatus }); + + const scheduled = await heartbeat.scheduleBoundedRetry(runId, { + now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }); + + expect(scheduled).toMatchObject({ + outcome: "not_scheduled", + errorCode: "issue_not_in_progress", + issueId, + }); + + const retryRuns = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.retryOfRunId, runId)) + .then((rows) => rows[0]?.count ?? 0); + expect(retryRuns).toBe(0); + }, + ); + + it.each(["blocked", "todo", "backlog"] as const)( + "cancels a due max-turn continuation when the issue moves to %s before retry promotion", + async (issueStatus) => { + const { issueId, runId, now } = await seedMaxTurnFixture(); + + const scheduled = await heartbeat.scheduleBoundedRetry(runId, { + now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }); + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + + await db.update(issues).set({ + status: issueStatus, + updatedAt: new Date(now.getTime() + 500), + }).where(eq(issues.id, issueId)); + + const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt); + expect(promotion).toEqual({ promoted: 0, runIds: [] }); + + const retryRun = await db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + wakeupRequestId: heartbeatRuns.wakeupRequestId, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect(retryRun).toMatchObject({ + status: "cancelled", + errorCode: "issue_not_in_progress", + }); + + const wakeupRequest = await db + .select({ status: agentWakeupRequests.status }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, retryRun?.wakeupRequestId ?? "")) + .then((rows) => rows[0] ?? null); + expect(wakeupRequest?.status).toBe("cancelled"); + + const issue = await db + .select({ + executionRunId: issues.executionRunId, + executionAgentNameKey: issues.executionAgentNameKey, + executionLockedAt: issues.executionLockedAt, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue).toEqual({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + }); + + const event = await db + .select({ + message: heartbeatRunEvents.message, + payload: heartbeatRunEvents.payload, + }) + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, scheduled.run.id)) + .orderBy(sql`${heartbeatRunEvents.seq} desc`) + .then((rows) => rows[0] ?? null); + expect(event?.message).toContain("no longer in_progress"); + expect(event?.payload).toMatchObject({ + currentStatus: issueStatus, + requiredStatus: "in_progress", + scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + }); + }, + ); + + it("does not queue max-turn continuations after the configured cap", async () => { + const { runId, now } = await seedMaxTurnFixture({ scheduledRetryAttempt: 2 }); + + const exhausted = await heartbeat.scheduleBoundedRetry(runId, { + now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }); + + expect(exhausted).toEqual({ + outcome: "retry_exhausted", + attempt: 3, + maxAttempts: 2, + }); + + const runCount = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .then((rows) => rows[0]?.count ?? 0); + expect(runCount).toBe(1); + + const exhaustionEvent = await db + .select({ message: heartbeatRunEvents.message, payload: heartbeatRunEvents.payload }) + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)) + .orderBy(sql`${heartbeatRunEvents.id} desc`) + .then((rows) => rows[0] ?? null); + expect(exhaustionEvent?.message).toContain("Bounded retry exhausted"); + expect(exhaustionEvent?.payload).toMatchObject({ + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + maxAttempts: 2, + }); + }); + + it("suppresses max-turn continuation scheduling when budget or dependencies block the issue", async () => { + const budgetBlocked = await seedMaxTurnFixture({ now: new Date("2026-04-20T16:00:00.000Z") }); + await db.insert(budgetPolicies).values({ + companyId: budgetBlocked.companyId, + scopeType: "agent", + scopeId: budgetBlocked.agentId, + windowKind: "monthly", + metric: "billed_cents", + amount: 0, + hardStopEnabled: true, + isActive: true, + }); + await db + .update(agents) + .set({ status: "paused", pauseReason: "budget" }) + .where(eq(agents.id, budgetBlocked.agentId)); + + const budgetResult = await heartbeat.scheduleBoundedRetry(budgetBlocked.runId, { + now: budgetBlocked.now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }); + expect(budgetResult).toMatchObject({ + outcome: "not_scheduled", + errorCode: "budget_blocked", + issueId: budgetBlocked.issueId, + }); + + await db.delete(budgetPolicies); + await db.delete(issueRelations); + await db.delete(issues); + await db.delete(heartbeatRunEvents); + await db.delete(heartbeatRuns); + await db.delete(agentWakeupRequests); + await db.delete(agentRuntimeState); + await db.delete(agents); + await db.delete(companies); + + const dependencyBlocked = await seedMaxTurnFixture({ now: new Date("2026-04-20T17:00:00.000Z") }); + const blockerId = randomUUID(); + await db.insert(issues).values({ + id: blockerId, + companyId: dependencyBlocked.companyId, + title: "Blocker", + status: "todo", + priority: "medium", + issueNumber: 2, + identifier: `T${dependencyBlocked.companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-2`, + }); + await db.insert(issueRelations).values({ + companyId: dependencyBlocked.companyId, + issueId: blockerId, + relatedIssueId: dependencyBlocked.issueId, + type: "blocks", + }); + + const dependencyResult = await heartbeat.scheduleBoundedRetry(dependencyBlocked.runId, { + now: dependencyBlocked.now, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: 2, + delayMs: 1_000, + }); + expect(dependencyResult).toMatchObject({ + outcome: "not_scheduled", + errorCode: "issue_dependencies_blocked", + issueId: dependencyBlocked.issueId, + }); + + const retryRuns = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.retryOfRunId, dependencyBlocked.runId)) + .then((rows) => rows[0]?.count ?? 0); + expect(retryRuns).toBe(0); + }); + it("does not defer a new assignee behind the previous assignee's scheduled retry", async () => { const companyId = randomUUID(); const oldAgentId = randomUUID(); diff --git a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts index 6c7d85dc709..4641d09f04e 100644 --- a/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts +++ b/server/src/__tests__/heartbeat-stale-queue-invalidation.test.ts @@ -23,7 +23,11 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { heartbeatService } from "../services/heartbeat.ts"; +import { + MAX_TURN_CONTINUATION_RETRY_REASON, + MAX_TURN_CONTINUATION_WAKE_REASON, + heartbeatService, +} from "../services/heartbeat.ts"; import { runningProcesses } from "../adapters/index.ts"; const mockAdapterExecute = vi.hoisted(() => @@ -189,6 +193,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { wakeReason: string; contextExtras?: Record; invocationSource?: "assignment" | "automation"; + scheduledRetryReason?: string | null; }) { const wakeupRequestId = randomUUID(); const runId = randomUUID(); @@ -210,6 +215,7 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { triggerDetail: "system", status: "queued", wakeupRequestId, + scheduledRetryReason: input.scheduledRetryReason ?? null, contextSnapshot: { issueId: input.issueId, wakeReason: input.wakeReason, @@ -345,6 +351,154 @@ describeEmbeddedPostgres("heartbeat stale queued-run invalidation", () => { expect(mockAdapterExecute).not.toHaveBeenCalled(); }); + it("cancels queued max-turn continuations when the issue is no longer in_progress before the run starts", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const issueId = randomUUID(); + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Parked max-turn continuation", + status: "blocked", + priority: "medium", + assigneeAgentId: agentId, + }); + + const { runId, wakeupRequestId } = await seedQueuedRun({ + companyId, + agentId, + issueId, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + invocationSource: "automation", + scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + contextExtras: { + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + }, + }); + + await heartbeat.resumeQueuedRuns(); + + await waitForCondition(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + return run?.status === "cancelled"; + }); + + const [run, wakeup] = await Promise.all([ + db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + resultJson: heartbeatRuns.resultJson, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null), + db + .select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, wakeupRequestId)) + .then((rows) => rows[0] ?? null), + ]); + + expect(run?.status).toBe("cancelled"); + expect(run?.errorCode).toBe("issue_not_in_progress"); + expect(run?.resultJson).toMatchObject({ stopReason: "issue_not_in_progress" }); + expect(wakeup?.status).toBe("skipped"); + expect(wakeup?.error).toContain("no longer in_progress"); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + }); + + it("cancels queued max-turn continuations when another continuation owns the issue lock", async () => { + const { companyId, agentId } = await seedCompanyAndAgent(); + const issueId = randomUUID(); + const lockOwnerRunId = randomUUID(); + + await db.insert(heartbeatRuns).values({ + id: lockOwnerRunId, + companyId, + agentId, + invocationSource: "automation", + triggerDetail: "system", + status: "scheduled_retry", + scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + scheduledRetryAttempt: 1, + scheduledRetryAt: new Date("2026-04-20T12:00:00.000Z"), + contextSnapshot: { + issueId, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + }, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Duplicate max-turn continuation", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + executionRunId: lockOwnerRunId, + executionAgentNameKey: "claudecoder", + executionLockedAt: new Date("2026-04-20T11:59:00.000Z"), + }); + + const { runId, wakeupRequestId } = await seedQueuedRun({ + companyId, + agentId, + issueId, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + invocationSource: "automation", + scheduledRetryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + contextExtras: { + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + }, + }); + + await heartbeat.resumeQueuedRuns(); + + await waitForCondition(async () => { + const run = await db + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + return run?.status === "cancelled"; + }); + + const [run, wakeup, issue] = await Promise.all([ + db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + resultJson: heartbeatRuns.resultJson, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null), + db + .select({ status: agentWakeupRequests.status, error: agentWakeupRequests.error }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, wakeupRequestId)) + .then((rows) => rows[0] ?? null), + db + .select({ executionRunId: issues.executionRunId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null), + ]); + + expect(run?.status).toBe("cancelled"); + expect(run?.errorCode).toBe("issue_execution_lock_changed"); + expect(run?.resultJson).toMatchObject({ stopReason: "issue_execution_lock_changed" }); + expect(wakeup?.status).toBe("skipped"); + expect(wakeup?.error).toContain("execution lock"); + expect(issue?.executionRunId).toBe(lockOwnerRunId); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + }); + it("cancels queued in_review runs when the current participant changes before the run starts", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const otherAgentId = randomUUID(); diff --git a/server/src/services/heartbeat-stop-metadata.test.ts b/server/src/services/heartbeat-stop-metadata.test.ts index fa0b636613f..fc6d54c82e9 100644 --- a/server/src/services/heartbeat-stop-metadata.test.ts +++ b/server/src/services/heartbeat-stop-metadata.test.ts @@ -64,6 +64,40 @@ describe("heartbeat stop metadata", () => { ).toBe("cancelled"); }); + it("normalizes max-turn exhaustion stop reasons", () => { + expect( + buildHeartbeatRunStopMetadata({ + adapterType: "claude_local", + adapterConfig: {}, + outcome: "failed", + errorCode: "turn_limit_exhausted", + errorMessage: "turn limit reached", + }).stopReason, + ).toBe("max_turns_exhausted"); + + const merged = mergeHeartbeatRunStopMetadata( + { stopReason: "turn_limit_exhausted" }, + buildHeartbeatRunStopMetadata({ + adapterType: "claude_local", + adapterConfig: {}, + outcome: "failed", + errorCode: "adapter_failed", + }), + ); + expect(merged.stopReason).toBe("max_turns_exhausted"); + }); + + it("prioritizes succeeded outcome over inconsistent max-turn error metadata", () => { + expect( + buildHeartbeatRunStopMetadata({ + adapterType: "claude_local", + adapterConfig: {}, + outcome: "succeeded", + errorCode: "max_turns_exhausted", + }).stopReason, + ).toBe("completed"); + }); + it("preserves existing result fields when merging stop metadata", () => { const result = mergeHeartbeatRunStopMetadata( { summary: "done" }, diff --git a/server/src/services/heartbeat-stop-metadata.ts b/server/src/services/heartbeat-stop-metadata.ts index e04c3b9ffa7..de80a32bba1 100644 --- a/server/src/services/heartbeat-stop-metadata.ts +++ b/server/src/services/heartbeat-stop-metadata.ts @@ -6,6 +6,7 @@ export type HeartbeatRunStopReason = | "cancelled" | "budget_paused" | "paused" + | "max_turns_exhausted" | "process_lost" | "adapter_failed"; @@ -40,6 +41,12 @@ function defaultTimeoutSecForAdapter(adapterType: string) { return adapterType === "openclaw_gateway" ? 120 : 0; } +export function normalizeMaxTurnStopReason(value: unknown): Extract | null { + return value === "max_turns_exhausted" || value === "turn_limit_exhausted" + ? "max_turns_exhausted" + : null; +} + export function resolveHeartbeatRunTimeoutPolicy( adapterType: string, adapterConfig: Record | null | undefined, @@ -76,6 +83,8 @@ export function inferHeartbeatRunStopReason(input: { errorMessage?: string | null; }): HeartbeatRunStopReason { if (input.outcome === "succeeded") return "completed"; + const maxTurnStopReason = normalizeMaxTurnStopReason(input.errorCode); + if (maxTurnStopReason) return maxTurnStopReason; if (input.outcome === "timed_out") return "timeout"; if (input.outcome === "failed" && input.errorCode === "process_lost") return "process_lost"; if (input.outcome === "cancelled") { @@ -107,9 +116,10 @@ export function mergeHeartbeatRunStopMetadata( resultJson: Record | null | undefined, metadata: HeartbeatRunStopMetadata, ): Record { + const existingMaxTurnStopReason = normalizeMaxTurnStopReason(resultJson?.stopReason); return { ...(resultJson ?? {}), - stopReason: metadata.stopReason, + stopReason: existingMaxTurnStopReason ?? metadata.stopReason, effectiveTimeoutSec: metadata.effectiveTimeoutSec, timeoutConfigured: metadata.timeoutConfigured, timeoutSource: metadata.timeoutSource, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 4d335f5ed4e..02ae14d2188 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -70,6 +70,7 @@ import { import { buildHeartbeatRunStopMetadata, mergeHeartbeatRunStopMetadata, + normalizeMaxTurnStopReason, } from "./heartbeat-stop-metadata.js"; import { classifyRunLiveness, @@ -189,12 +190,25 @@ const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_JITTER_RATIO = 0.25; const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON = "transient_failure"; const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON = "transient_failure_retry"; const BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS = BOUNDED_TRANSIENT_HEARTBEAT_RETRY_DELAYS_MS.length; +export const MAX_TURN_CONTINUATION_RETRY_REASON = "max_turns_continuation"; +export const MAX_TURN_CONTINUATION_WAKE_REASON = "max_turns_continuation_retry"; +const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; +const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10; +const MAX_TURN_CONTINUATION_DEFAULT_DELAY_MS = 1_000; +const MAX_TURN_CONTINUATION_MAX_DELAY_MS = 5 * 60 * 1000; +const MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES = ["scheduled_retry", "queued", "running"] as const; type CodexTransientFallbackMode = | "same_session" | "safer_invocation" | "fresh_session" | "fresh_session_safer_invocation"; +interface MaxTurnContinuationPolicy { + enabled: boolean; + maxAttempts: number; + delayMs: number; +} + function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallbackMode { if (attempt <= 1) return "same_session"; if (attempt === 2) return "safer_invocation"; @@ -215,6 +229,16 @@ function readHeartbeatRunErrorFamily( return null; } +function isMaxTurnExhaustionRun( + run: Pick, +) { + const resultJson = parseObject(run.resultJson); + return Boolean( + normalizeMaxTurnStopReason(resultJson.stopReason) ?? + normalizeMaxTurnStopReason(run.errorCode), + ); +} + function readTransientRetryNotBeforeFromRun(run: Pick) { const resultJson = parseObject(run.resultJson); const value = resultJson.retryNotBefore ?? resultJson.transientRetryNotBefore; @@ -4289,6 +4313,274 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return queued; } + type ScheduledRetryGate = + | { allowed: true } + | { + allowed: false; + reason: string; + errorCode: + | "agent_not_invokable" + | "budget_blocked" + | "issue_not_found" + | "issue_reassigned" + | "issue_cancelled" + | "issue_terminal_status" + | "issue_not_in_progress" + | "issue_execution_lock_changed" + | "issue_review_participant_changed" + | "issue_paused" + | "issue_dependencies_blocked"; + issueId: string | null; + details: Record; + }; + + async function evaluateScheduledRetryGate(input: { + run: typeof heartbeatRuns.$inferSelect; + agent: typeof agents.$inferSelect; + contextSnapshot: Record; + retryReason?: string | null; + enforceIssueExecutionLock?: boolean; + }): Promise { + const { run, agent, contextSnapshot } = input; + const retryReason = + input.retryReason ?? readNonEmptyString(contextSnapshot.retryReason) ?? run.scheduledRetryReason ?? null; + const issueId = readNonEmptyString(contextSnapshot.issueId); + const projectId = readNonEmptyString(contextSnapshot.projectId); + + const budgetBlock = await budgets.getInvocationBlock(run.companyId, run.agentId, { + issueId, + projectId, + }); + if (budgetBlock) { + return { + allowed: false, + reason: budgetBlock.reason, + errorCode: "budget_blocked", + issueId, + details: { + scopeType: budgetBlock.scopeType, + scopeId: budgetBlock.scopeId, + }, + }; + } + + if (agent.status === "paused" || agent.status === "terminated" || agent.status === "pending_approval") { + return { + allowed: false, + reason: "Scheduled retry suppressed because the agent is not invokable", + errorCode: "agent_not_invokable", + issueId, + details: { + agentId: agent.id, + agentStatus: agent.status, + }, + }; + } + + if (!issueId) return { allowed: true }; + + const issue = await db + .select({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + executionState: issues.executionState, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))) + .then((rows) => rows[0] ?? null); + + if (!issue) { + return { + allowed: false, + reason: "Scheduled retry suppressed because the target issue no longer exists", + errorCode: "issue_not_found", + issueId, + details: { issueId }, + }; + } + + if (issue.assigneeAgentId !== run.agentId) { + return { + allowed: false, + reason: "Scheduled retry suppressed because issue ownership changed", + errorCode: "issue_reassigned", + issueId, + details: { + issueId, + previousAssigneeAgentId: run.agentId, + currentAssigneeAgentId: issue.assigneeAgentId, + }, + }; + } + + if (issue.status === "cancelled" || issue.status === "done") { + return { + allowed: false, + reason: `Scheduled retry suppressed because issue reached terminal status (${issue.status})`, + errorCode: issue.status === "cancelled" ? "issue_cancelled" : "issue_terminal_status", + issueId, + details: { issueId, currentStatus: issue.status }, + }; + } + + if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON && issue.status !== "in_progress") { + return { + allowed: false, + reason: `Scheduled max-turn continuation suppressed because issue is no longer in_progress (current status: ${issue.status})`, + errorCode: "issue_not_in_progress", + issueId, + details: { issueId, currentStatus: issue.status, requiredStatus: "in_progress" }, + }; + } + + if ( + retryReason === MAX_TURN_CONTINUATION_RETRY_REASON && + input.enforceIssueExecutionLock && + issue.executionRunId !== run.id + ) { + return { + allowed: false, + reason: "Scheduled max-turn continuation suppressed because the issue execution lock belongs to a different run", + errorCode: "issue_execution_lock_changed", + issueId, + details: { + issueId, + expectedExecutionRunId: run.id, + currentExecutionRunId: issue.executionRunId, + }, + }; + } + + if (issue.status === "in_review") { + const executionState = parseIssueExecutionState(issue.executionState); + const currentParticipant = executionState?.currentParticipant ?? null; + if (currentParticipant) { + const participantMatches = + currentParticipant.type === "agent" && currentParticipant.agentId === run.agentId; + if (!participantMatches) { + return { + allowed: false, + reason: "Scheduled retry suppressed because the issue is waiting on another review participant", + errorCode: "issue_review_participant_changed", + issueId, + details: { + issueId, + currentStageType: executionState?.currentStageType ?? null, + currentParticipant, + }, + }; + } + } + } + + const activePauseHold = await treeControlSvc.getActivePauseHoldGate(run.companyId, issueId); + if (activePauseHold) { + return { + allowed: false, + reason: "Scheduled retry suppressed because the issue is held by an active subtree pause hold", + errorCode: "issue_paused", + issueId, + details: { + issueId, + holdId: activePauseHold.holdId, + rootIssueId: activePauseHold.rootIssueId, + }, + }; + } + + const dependencyReadiness = await issuesSvc.listDependencyReadiness(run.companyId, [issueId]); + const readiness = dependencyReadiness.get(issueId); + if (readiness && !readiness.isDependencyReady) { + return { + allowed: false, + reason: "Scheduled retry suppressed because issue dependencies are still blocked", + errorCode: "issue_dependencies_blocked", + issueId, + details: { + issueId, + unresolvedBlockerIssueIds: readiness.unresolvedBlockerIssueIds, + unresolvedBlockerCount: readiness.unresolvedBlockerCount, + }, + }; + } + + return { allowed: true }; + } + + async function cancelScheduledRetryForGate( + run: typeof heartbeatRuns.$inferSelect, + gate: Extract, + now: Date, + ) { + const cancelled = await db + .update(heartbeatRuns) + .set({ + status: "cancelled", + finishedAt: now, + error: gate.reason, + errorCode: gate.errorCode, + updatedAt: now, + }) + .where( + and( + eq(heartbeatRuns.id, run.id), + eq(heartbeatRuns.status, "scheduled_retry"), + lte(heartbeatRuns.scheduledRetryAt, now), + ), + ) + .returning() + .then((rows) => rows[0] ?? null); + + if (!cancelled) return null; + + if (cancelled.wakeupRequestId) { + await db + .update(agentWakeupRequests) + .set({ + status: "cancelled", + finishedAt: now, + error: gate.reason, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, cancelled.wakeupRequestId)); + } + + if (gate.issueId) { + await db + .update(issues) + .set({ + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + updatedAt: now, + }) + .where( + and( + eq(issues.companyId, cancelled.companyId), + eq(issues.id, gate.issueId), + eq(issues.executionRunId, cancelled.id), + ), + ); + } + + await appendRunEvent(cancelled, await nextRunEventSeq(cancelled.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: gate.reason, + payload: { + ...gate.details, + scheduledRetryAttempt: cancelled.scheduledRetryAttempt, + scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null, + scheduledRetryReason: cancelled.scheduledRetryReason, + }, + }); + + return cancelled; + } + async function scheduleBoundedRetryForRun( run: typeof heartbeatRuns.$inferSelect, agent: typeof agents.$inferSelect, @@ -4297,13 +4589,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) random?: () => number; retryReason?: string; wakeReason?: string; + maxAttempts?: number; + delayMs?: number; }, ) { const now = opts?.now ?? new Date(); const retryReason = opts?.retryReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON; const wakeReason = opts?.wakeReason ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_WAKE_REASON; + const maxAttempts = Math.max(0, Math.floor(opts?.maxAttempts ?? BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS)); const nextAttempt = (run.scheduledRetryAttempt ?? 0) + 1; - const baseSchedule = computeBoundedTransientHeartbeatRetrySchedule(nextAttempt, now, opts?.random); + const baseSchedule = opts?.delayMs != null + ? nextAttempt <= maxAttempts + ? { + attempt: nextAttempt, + baseDelayMs: Math.max(0, Math.floor(opts.delayMs)), + delayMs: Math.max(0, Math.floor(opts.delayMs)), + dueAt: new Date(now.getTime() + Math.max(0, Math.floor(opts.delayMs))), + maxAttempts, + } + : null + : nextAttempt <= maxAttempts + ? computeBoundedTransientHeartbeatRetrySchedule(nextAttempt, now, opts?.random) + : null; const transientRecovery = retryReason === BOUNDED_TRANSIENT_HEARTBEAT_RETRY_REASON ? readTransientRecoveryContractFromRun(run) @@ -4323,13 +4630,13 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) payload: { retryReason, scheduledRetryAttempt: run.scheduledRetryAttempt ?? 0, - maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + maxAttempts, }, }); return { outcome: "retry_exhausted" as const, attempt: nextAttempt, - maxAttempts: BOUNDED_TRANSIENT_HEARTBEAT_RETRY_MAX_ATTEMPTS, + maxAttempts, }; } const schedule = @@ -4343,6 +4650,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const contextSnapshot = parseObject(run.contextSnapshot); const issueId = readNonEmptyString(contextSnapshot.issueId); + if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON) { + const gate = await evaluateScheduledRetryGate({ run, agent, contextSnapshot, retryReason }); + if (!gate.allowed) { + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: gate.reason, + payload: { + retryReason, + scheduledRetryAttempt: nextAttempt, + maxAttempts, + ...gate.details, + }, + }); + return { + outcome: "not_scheduled" as const, + reason: gate.reason, + errorCode: gate.errorCode, + issueId: gate.issueId, + }; + } + } const taskKey = deriveTaskKeyWithHeartbeatFallback(contextSnapshot, null); const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); const retryContextSnapshot: Record = { @@ -4356,8 +4686,158 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ...(transientRetryNotBefore ? { transientRetryNotBefore: transientRetryNotBefore.toISOString() } : {}), ...(codexTransientFallbackMode ? { codexTransientFallbackMode } : {}), }; + const maxTurnContinuationIdempotencyKey = retryReason === MAX_TURN_CONTINUATION_RETRY_REASON + ? `max-turn-continuation:${run.companyId}:${issueId ?? "no-issue"}:${run.id}:${schedule.attempt}` + : null; + + type ScheduledRetryTransactionResult = + | { + outcome: "scheduled"; + run: typeof heartbeatRuns.$inferSelect; + reusedExisting: boolean; + } + | { + outcome: "not_scheduled"; + reason: string; + errorCode: + | "issue_not_found" + | "issue_reassigned" + | "issue_cancelled" + | "issue_terminal_status" + | "issue_not_in_progress" + | "issue_execution_lock_changed"; + issueId: string | null; + details: Record; + }; + + const scheduleResult = await db.transaction(async (tx): Promise => { + if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON) { + if (issueId) { + await tx.execute( + sql`select id from issues where company_id = ${run.companyId} and id = ${issueId} for update`, + ); + } else { + await tx.execute( + sql`select id from heartbeat_runs where company_id = ${run.companyId} and id = ${run.id} for update`, + ); + } + + const existingContinuation = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, run.companyId), + eq(heartbeatRuns.retryOfRunId, run.id), + eq(heartbeatRuns.scheduledRetryReason, retryReason), + eq(heartbeatRuns.scheduledRetryAttempt, schedule.attempt), + inArray(heartbeatRuns.status, [...MAX_TURN_CONTINUATION_LIVE_RUN_STATUSES]), + issueId + ? sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}` + : sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' is null`, + ), + ) + .orderBy(asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existingContinuation) { + if (existingContinuation.wakeupRequestId) { + const existingWakeup = await tx + .select({ coalescedCount: agentWakeupRequests.coalescedCount }) + .from(agentWakeupRequests) + .where(eq(agentWakeupRequests.id, existingContinuation.wakeupRequestId)) + .then((rows) => rows[0] ?? null); + + await tx + .update(agentWakeupRequests) + .set({ + coalescedCount: (existingWakeup?.coalescedCount ?? 0) + 1, + updatedAt: now, + }) + .where(eq(agentWakeupRequests.id, existingContinuation.wakeupRequestId)); + } + + return { + outcome: "scheduled", + run: existingContinuation, + reusedExisting: true, + }; + } + + if (issueId) { + const lockedIssue = await tx + .select({ + id: issues.id, + status: issues.status, + assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId))) + .then((rows) => rows[0] ?? null); + + if (!lockedIssue) { + return { + outcome: "not_scheduled", + reason: "Scheduled max-turn continuation suppressed because the target issue no longer exists", + errorCode: "issue_not_found", + issueId, + details: { issueId }, + }; + } + + if (lockedIssue.assigneeAgentId !== run.agentId) { + return { + outcome: "not_scheduled", + reason: "Scheduled max-turn continuation suppressed because issue ownership changed", + errorCode: "issue_reassigned", + issueId, + details: { + issueId, + previousAssigneeAgentId: run.agentId, + currentAssigneeAgentId: lockedIssue.assigneeAgentId, + }, + }; + } + + if (lockedIssue.status === "cancelled" || lockedIssue.status === "done") { + return { + outcome: "not_scheduled", + reason: `Scheduled max-turn continuation suppressed because issue reached terminal status (${lockedIssue.status})`, + errorCode: lockedIssue.status === "cancelled" ? "issue_cancelled" : "issue_terminal_status", + issueId, + details: { issueId, currentStatus: lockedIssue.status }, + }; + } + + if (lockedIssue.status !== "in_progress") { + return { + outcome: "not_scheduled", + reason: `Scheduled max-turn continuation suppressed because issue is no longer in_progress (current status: ${lockedIssue.status})`, + errorCode: "issue_not_in_progress", + issueId, + details: { issueId, currentStatus: lockedIssue.status, requiredStatus: "in_progress" }, + }; + } + + if (lockedIssue.executionRunId !== run.id) { + return { + outcome: "not_scheduled", + reason: + "Scheduled max-turn continuation suppressed because the issue execution lock belongs to a different run", + errorCode: "issue_execution_lock_changed", + issueId, + details: { + issueId, + expectedExecutionRunId: run.id, + currentExecutionRunId: lockedIssue.executionRunId, + }, + }; + } + } + } - const retryRun = await db.transaction(async (tx) => { const wakeupRequest = await tx .insert(agentWakeupRequests) .values({ @@ -4379,6 +4859,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) status: "queued", requestedByActorType: "system", requestedByActorId: null, + idempotencyKey: maxTurnContinuationIdempotencyKey, updatedAt: now, }) .returning() @@ -4425,9 +4906,62 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId), eq(issues.executionRunId, run.id))); } - return scheduledRun; + return { + outcome: "scheduled", + run: scheduledRun, + reusedExisting: false, + }; }); + if (scheduleResult.outcome === "not_scheduled") { + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: scheduleResult.reason, + payload: { + retryReason, + scheduledRetryAttempt: nextAttempt, + maxAttempts, + ...scheduleResult.details, + }, + }); + return { + outcome: "not_scheduled" as const, + reason: scheduleResult.reason, + errorCode: scheduleResult.errorCode, + issueId: scheduleResult.issueId, + }; + } + + const retryRun = scheduleResult.run; + const dueAt = retryRun.scheduledRetryAt ? new Date(retryRun.scheduledRetryAt) : schedule.dueAt; + + if (scheduleResult.reusedExisting) { + await appendRunEvent(run, await nextRunEventSeq(run.id), { + eventType: "lifecycle", + stream: "system", + level: "info", + message: `Reused existing max-turn continuation ${retryRun.scheduledRetryAttempt}/${schedule.maxAttempts}`, + payload: { + retryRunId: retryRun.id, + retryReason, + idempotencyKey: maxTurnContinuationIdempotencyKey, + scheduledRetryAttempt: retryRun.scheduledRetryAttempt, + scheduledRetryAt: dueAt.toISOString(), + }, + }); + + return { + outcome: "scheduled" as const, + run: retryRun, + dueAt, + attempt: retryRun.scheduledRetryAttempt, + maxAttempts: schedule.maxAttempts, + reusedExisting: true, + }; + } + await appendRunEvent(run, await nextRunEventSeq(run.id), { eventType: "lifecycle", stream: "system", @@ -4449,7 +4983,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return { outcome: "scheduled" as const, run: retryRun, - dueAt: schedule.dueAt, + dueAt, attempt: schedule.attempt, maxAttempts: schedule.maxAttempts, }; @@ -4471,86 +5005,35 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const promotedRunIds: string[] = []; for (const dueRun of dueRuns) { - const dueRunIssueId = readNonEmptyString(parseObject(dueRun.contextSnapshot).issueId); - if (dueRunIssueId) { - const issue = await db - .select({ - id: issues.id, - status: issues.status, - assigneeAgentId: issues.assigneeAgentId, - executionRunId: issues.executionRunId, - }) - .from(issues) - .where(and(eq(issues.id, dueRunIssueId), eq(issues.companyId, dueRun.companyId))) - .then((rows) => rows[0] ?? null); + const agent = await getAgent(dueRun.agentId); + if (!agent) { + await cancelScheduledRetryForGate(dueRun, { + allowed: false, + reason: "Scheduled retry suppressed because the agent no longer exists", + errorCode: "agent_not_invokable", + issueId: readNonEmptyString(parseObject(dueRun.contextSnapshot).issueId), + details: { agentId: dueRun.agentId }, + }, now); + continue; + } - if (issue && (issue.assigneeAgentId !== dueRun.agentId || issue.status === "cancelled")) { - const issueCancelled = issue.status === "cancelled"; - const reason = issueCancelled - ? "Cancelled because the issue was cancelled before the scheduled retry became due" - : "Cancelled because the issue was reassigned before the scheduled retry became due"; - const cancelled = await db - .update(heartbeatRuns) - .set({ - status: "cancelled", - finishedAt: now, - error: reason, - errorCode: issueCancelled ? "issue_cancelled" : "issue_reassigned", - updatedAt: now, - }) - .where( - and( - eq(heartbeatRuns.id, dueRun.id), - eq(heartbeatRuns.status, "scheduled_retry"), - lte(heartbeatRuns.scheduledRetryAt, now), - ), - ) - .returning() - .then((rows) => rows[0] ?? null); - - if (!cancelled) continue; - - if (cancelled.wakeupRequestId) { - await db - .update(agentWakeupRequests) - .set({ - status: "cancelled", - finishedAt: now, - error: reason, - updatedAt: now, - }) - .where(eq(agentWakeupRequests.id, cancelled.wakeupRequestId)); - } - - if (issue.executionRunId === cancelled.id) { - await db - .update(issues) - .set({ - executionRunId: null, - executionAgentNameKey: null, - executionLockedAt: null, - updatedAt: now, - }) - .where(and(eq(issues.id, issue.id), eq(issues.executionRunId, cancelled.id))); - } - - await appendRunEvent(cancelled, await nextRunEventSeq(cancelled.id), { - eventType: "lifecycle", - stream: "system", - level: "warn", - message: issueCancelled - ? "Scheduled retry cancelled because issue was cancelled before it became due" - : "Scheduled retry cancelled because issue ownership changed before it became due", - payload: { - issueId: issue.id, - issueStatus: issue.status, - scheduledRetryAttempt: cancelled.scheduledRetryAttempt, - scheduledRetryAt: cancelled.scheduledRetryAt ? new Date(cancelled.scheduledRetryAt).toISOString() : null, - scheduledRetryReason: cancelled.scheduledRetryReason, - previousRetryAgentId: cancelled.agentId, - currentAssigneeAgentId: issue.assigneeAgentId, - }, - }); + const contextSnapshot = parseObject(dueRun.contextSnapshot); + const gate = await evaluateScheduledRetryGate({ + run: dueRun, + agent, + contextSnapshot, + retryReason: dueRun.scheduledRetryReason, + enforceIssueExecutionLock: dueRun.scheduledRetryReason === MAX_TURN_CONTINUATION_RETRY_REASON, + }); + if (!gate.allowed) { + if ( + gate.errorCode === "issue_not_found" && + dueRun.scheduledRetryReason !== MAX_TURN_CONTINUATION_RETRY_REASON + ) { + // Preserve legacy transient retry behavior for runs that only carry a + // loose task context rather than a persisted issue row. + } else { + await cancelScheduledRetryForGate(dueRun, gate, now); continue; } } @@ -4617,6 +5100,20 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; } + function parseMaxTurnContinuationPolicy(agent: typeof agents.$inferSelect): MaxTurnContinuationPolicy { + const runtimeConfig = parseObject(agent.runtimeConfig); + const heartbeat = parseObject(runtimeConfig.heartbeat); + const configured = parseObject(heartbeat.maxTurnContinuation); + const rawMaxAttempts = Math.floor(asNumber(configured.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS)); + const rawDelayMs = Math.floor(asNumber(configured.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_MS)); + + return { + enabled: asBoolean(configured.enabled, true), + maxAttempts: Math.max(0, Math.min(MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP, rawMaxAttempts)), + delayMs: Math.max(0, Math.min(MAX_TURN_CONTINUATION_MAX_DELAY_MS, rawDelayMs)), + }; + } + function issueRunPriorityRank(priority: string | null | undefined) { switch (priority) { case "critical": @@ -4857,6 +5354,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) | "issue_not_found" | "issue_assignee_changed" | "issue_terminal_status" + | "issue_not_in_progress" + | "issue_execution_lock_changed" | "issue_review_participant_changed"; details: Record; }; @@ -4871,6 +5370,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) id: issues.id, status: issues.status, assigneeAgentId: issues.assigneeAgentId, + executionRunId: issues.executionRunId, executionState: issues.executionState, }) .from(issues) @@ -4889,6 +5389,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const wakeCommentId = deriveCommentId(context, null); const isInteractionWake = allowsIssueInteractionWake(context); const resumeIntent = context.resumeIntent === true || context.followUpRequested === true; + const retryReason = readNonEmptyString(context.retryReason) ?? run.scheduledRetryReason ?? null; if (issue.assigneeAgentId !== run.agentId && !isInteractionWake) { return { @@ -4915,6 +5416,29 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } } + if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON && issue.status !== "in_progress") { + return { + stale: true, + errorCode: "issue_not_in_progress", + reason: `Cancelled because max-turn continuation issue is no longer in_progress (current status: ${issue.status}) before the queued run could start`, + details: { issueId, currentStatus: issue.status, requiredStatus: "in_progress" }, + }; + } + + if (retryReason === MAX_TURN_CONTINUATION_RETRY_REASON && issue.executionRunId !== run.id) { + return { + stale: true, + errorCode: "issue_execution_lock_changed", + reason: + "Cancelled because max-turn continuation no longer owns the issue execution lock before the queued run could start", + details: { + issueId, + expectedExecutionRunId: run.id, + currentExecutionRunId: issue.executionRunId, + }, + }; + } + if (issue.status === "in_review") { const executionState = parseIssueExecutionState(issue.executionState); const currentParticipant = executionState?.currentParticipant ?? null; @@ -6683,7 +7207,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ); } } - if (outcome === "failed" && readTransientRecoveryContractFromRun(livenessRun)) { + if (outcome === "failed" && isMaxTurnExhaustionRun(livenessRun)) { + const policy = parseMaxTurnContinuationPolicy(agent); + if (policy.enabled && policy.maxAttempts > 0) { + await scheduleBoundedRetryForRun(livenessRun, agent, { + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + wakeReason: MAX_TURN_CONTINUATION_WAKE_REASON, + maxAttempts: policy.maxAttempts, + delayMs: policy.delayMs, + }); + } else { + await appendRunEvent(livenessRun, await nextRunEventSeq(livenessRun.id), { + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "Max-turn continuation suppressed because the policy is disabled", + payload: { + retryReason: MAX_TURN_CONTINUATION_RETRY_REASON, + policy, + }, + }); + } + } else if (outcome === "failed" && readTransientRecoveryContractFromRun(livenessRun)) { await scheduleBoundedRetryForRun(livenessRun, agent); } await finalizeIssueCommentPolicy(livenessRun, agent); @@ -8443,6 +8988,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) random?: () => number; retryReason?: string; wakeReason?: string; + maxAttempts?: number; + delayMs?: number; }, ) => { const run = await getRun(runId, { unsafeFullResultJson: true }); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index c36463ab9f9..5e232665e99 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -27,7 +27,7 @@ import { } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; import { FolderOpen, Heart, ChevronDown, X } from "lucide-react"; -import { cn } from "../lib/utils"; +import { asBoolean, asFiniteNumber, asObject, cn } from "../lib/utils"; import { extractModelName, extractProviderId } from "../lib/model-utils"; import { queryKeys } from "../lib/queryKeys"; import { useCompany } from "../context/CompanyContext"; @@ -175,6 +175,19 @@ const claudeThinkingEffortOptions = [ { id: "high", label: "High" }, ] as const; +const MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS = 2; +const MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP = 10; +const MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC = 1; +const MAX_TURN_CONTINUATION_MAX_DELAY_SEC = 300; + +function clampInteger(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, Math.floor(value))); +} + +function clampDelayMsFromSeconds(value: number) { + return clampInteger(value, 0, MAX_TURN_CONTINUATION_MAX_DELAY_SEC) * 1000; +} + /* ---- Form ---- */ @@ -628,6 +641,27 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const currentDefaultEnvironmentId = isCreate ? val!.defaultEnvironmentId ?? "" : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); + const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat); + const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation); + const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true); + const maxTurnContinuationMaxAttempts = clampInteger( + asFiniteNumber(maxTurnContinuation.maxAttempts, MAX_TURN_CONTINUATION_DEFAULT_MAX_ATTEMPTS), + 0, + MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP, + ); + const maxTurnContinuationDelaySec = clampInteger( + asFiniteNumber(maxTurnContinuation.delayMs, MAX_TURN_CONTINUATION_DEFAULT_DELAY_SEC * 1000) / 1000, + 0, + MAX_TURN_CONTINUATION_MAX_DELAY_SEC, + ); + + function updateMaxTurnContinuation(patch: Record) { + mark("heartbeat", "maxTurnContinuation", { + ...maxTurnContinuation, + ...patch, + }); + } + return (
{/* ---- Floating Save button (edit mode, when dirty) ---- */} @@ -1182,6 +1216,40 @@ export function AgentConfigForm(props: AgentConfigFormProps) { className={inputClass} /> +
+ updateMaxTurnContinuation({ enabled: v })} + /> + {maxTurnContinuationEnabled ? ( +
+ + + updateMaxTurnContinuation({ + maxAttempts: clampInteger(v, 0, MAX_TURN_CONTINUATION_MAX_ATTEMPTS_CAP), + })} + immediate + className={inputClass} + /> + + + + updateMaxTurnContinuation({ + delayMs: clampDelayMsFromSeconds(v), + })} + immediate + className={inputClass} + /> + +
+ ) : null} +
diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index e3baabc4420..9913971a724 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -317,6 +317,44 @@ describe("IssueRunLedger", () => { expect(container.textContent).toContain("Manual intervention required"); }); + it("labels max-turn stops and continuation retries without confusing them with per-run turns", () => { + renderLedger({ + runs: [ + createRun({ + runId: "run-scheduled-continuation", + status: "scheduled_retry", + finishedAt: null, + livenessState: null, + livenessReason: null, + retryOfRunId: "run-max-turns", + scheduledRetryAt: "2026-04-18T20:15:00.000Z", + scheduledRetryAttempt: 1, + scheduledRetryReason: "max_turns_continuation", + }), + createRun({ + runId: "run-max-turns", + resultJson: { stopReason: "max_turns_exhausted" }, + createdAt: "2026-04-18T19:57:00.000Z", + }), + createRun({ + runId: "run-continuation-exhausted", + status: "failed", + createdAt: "2026-04-18T19:56:00.000Z", + retryOfRunId: "run-max-turns", + scheduledRetryAttempt: 3, + scheduledRetryReason: "max_turns_continuation", + retryExhaustedReason: "Bounded retry exhausted after 3 scheduled attempts; no further automatic retry will be queued", + }), + ], + }); + + expect(container.textContent).toContain("Continuation scheduled"); + expect(container.textContent).toContain("Max-turn continuation"); + expect(container.textContent).toContain("Next continuation"); + expect(container.textContent).toContain("Stop max turns exhausted"); + expect(container.textContent).toContain("Continuation exhausted"); + }); + it("shows timeout, cancel, and budget stop reasons without raw logs", () => { renderLedger({ runs: [ diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index e45bd4ae28b..7ae52e1a6f1 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -311,6 +311,7 @@ function stopReasonLabel(run: RunForIssue) { if (timeoutFired || stopReason === "timeout") { return timeoutText ? `timeout (${timeoutText})` : "timeout"; } + if (stopReason === "max_turns_exhausted" || stopReason === "turn_limit_exhausted") return "max turns exhausted"; if (stopReason === "budget_paused") return "budget paused"; if (stopReason === "cancelled") return "cancelled"; if (stopReason === "paused") return "paused by board"; diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index d5c0f4cad06..6cfa9985d6d 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -56,6 +56,9 @@ export const help: Record = { wakeOnDemand: "Allow this agent to be woken by assignments, API calls, UI actions, or automated systems.", cooldownSec: "Minimum seconds between consecutive heartbeat runs.", maxConcurrentRuns: "Maximum number of heartbeat runs that can execute simultaneously for this agent.", + maxTurnContinuationEnabled: "Automatically queue bounded continuation runs when an adapter stops because its per-run turn cap was exhausted.", + maxTurnContinuationMaxAttempts: "Maximum automatic continuations after one max-turn stop. This is separate from max turns per run.", + maxTurnContinuationDelaySec: "Seconds to wait before starting each max-turn continuation.", budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.", }; diff --git a/ui/src/lib/agent-config-patch.test.ts b/ui/src/lib/agent-config-patch.test.ts index caa6a49fdbe..7cdc79e2a26 100644 --- a/ui/src/lib/agent-config-patch.test.ts +++ b/ui/src/lib/agent-config-patch.test.ts @@ -108,6 +108,35 @@ describe("buildAgentUpdatePatch", () => { expect(patch.adapterConfig).toBeUndefined(); }); + it("writes max-turn continuation policy under runtimeConfig.heartbeat", () => { + const patch = buildAgentUpdatePatch( + makeAgent(), + makeOverlay({ + heartbeat: { + maxTurnContinuation: { + enabled: true, + maxAttempts: 3, + delayMs: 1000, + }, + }, + }), + ); + + expect(patch).toEqual({ + runtimeConfig: { + heartbeat: { + enabled: true, + intervalSec: 300, + maxTurnContinuation: { + enabled: true, + maxAttempts: 3, + delayMs: 1000, + }, + }, + }, + }); + }); + it("merges cheap profile changes onto existing runtimeConfig.modelProfiles state", () => { const agent = makeAgent(); agent.runtimeConfig = { diff --git a/ui/src/lib/runRetryState.test.ts b/ui/src/lib/runRetryState.test.ts index cc6ae84cfe9..9549cc3089f 100644 --- a/ui/src/lib/runRetryState.test.ts +++ b/ui/src/lib/runRetryState.test.ts @@ -5,6 +5,7 @@ describe("runRetryState", () => { it("formats internal retry reasons for operators", () => { expect(formatRetryReason("transient_failure")).toBe("Transient failure"); expect(formatRetryReason("issue_continuation_needed")).toBe("Continuation needed"); + expect(formatRetryReason("max_turns_continuation")).toBe("Max-turn continuation"); expect(formatRetryReason("custom_reason")).toBe("custom reason"); }); @@ -24,6 +25,22 @@ describe("runRetryState", () => { }); }); + it("describes max-turn continuation retries distinctly", () => { + expect( + describeRunRetryState({ + status: "scheduled_retry", + retryOfRunId: "run-max-turns", + scheduledRetryAttempt: 1, + scheduledRetryReason: "max_turns_continuation", + scheduledRetryAt: "2026-04-18T20:15:00.000Z", + }), + ).toMatchObject({ + kind: "scheduled", + badgeLabel: "Continuation scheduled", + detail: "Attempt 1 · Max-turn continuation", + }); + }); + it("describes exhausted retries", () => { expect( describeRunRetryState({ diff --git a/ui/src/lib/runRetryState.ts b/ui/src/lib/runRetryState.ts index 1df9c7883c4..0b5d356bc60 100644 --- a/ui/src/lib/runRetryState.ts +++ b/ui/src/lib/runRetryState.ts @@ -24,6 +24,7 @@ const RETRY_REASON_LABELS: Record = { process_lost: "Process lost", assignment_recovery: "Assignment recovery", issue_continuation_needed: "Continuation needed", + max_turns_continuation: "Max-turn continuation", }; function readNonEmptyString(value: unknown) { @@ -51,6 +52,7 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary const retryOfRunId = readNonEmptyString(run.retryOfRunId); const exhaustedReason = readNonEmptyString(run.retryExhaustedReason); const dueAt = run.scheduledRetryAt ? formatDateTime(run.scheduledRetryAt) : null; + const isMaxTurnContinuation = run.scheduledRetryReason === "max_turns_continuation"; const hasRetryMetadata = Boolean(retryOfRunId) || Boolean(reasonLabel) @@ -63,10 +65,12 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary if (run.status === "scheduled_retry") { return { kind: "scheduled", - badgeLabel: "Retry scheduled", + badgeLabel: isMaxTurnContinuation ? "Continuation scheduled" : "Retry scheduled", tone: "border-cyan-500/30 bg-cyan-500/10 text-cyan-700 dark:text-cyan-300", detail: joinFragments([attemptLabel, reasonLabel]), - secondary: dueAt ? `Next retry ${dueAt}` : "Next retry pending schedule", + secondary: dueAt + ? `${isMaxTurnContinuation ? "Next continuation" : "Next retry"} ${dueAt}` + : `${isMaxTurnContinuation ? "Next continuation" : "Next retry"} pending schedule`, retryOfRunId, }; } @@ -74,7 +78,7 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary if (exhaustedReason) { return { kind: "exhausted", - badgeLabel: "Retry exhausted", + badgeLabel: isMaxTurnContinuation ? "Continuation exhausted" : "Retry exhausted", tone: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", detail: joinFragments([attemptLabel, reasonLabel, "Automatic retries exhausted"]), secondary: exhaustedReason.includes("Manual intervention required") @@ -86,7 +90,7 @@ export function describeRunRetryState(run: RetryAwareRun): RunRetryStateSummary return { kind: "attempted", - badgeLabel: "Retried run", + badgeLabel: isMaxTurnContinuation ? "Continued run" : "Retried run", tone: "border-slate-500/20 bg-slate-500/10 text-slate-700 dark:text-slate-300", detail: joinFragments([attemptLabel, reasonLabel]), secondary: null, diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 65a2e42c221..ee47b146e9a 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -7,6 +7,20 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +export function asObject(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +export function asBoolean(value: unknown, fallback: boolean) { + return typeof value === "boolean" ? value : fallback; +} + +export function asFiniteNumber(value: unknown, fallback: number) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + export function formatCents(cents: number): string { return `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } From a7b45938b7bbc3d3ef458fac8831c74182e652df Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 12:19:35 -0700 Subject: [PATCH 02/27] Let sandbox providers declare shell defaults (#5114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents execute in sandboxed remote environments served by pluggable sandbox > providers (E2B today, more later) > - Today every sandbox command runs under `sh -lc` regardless of what the > provider's container actually ships > - That misses bash-only shell init on E2B (which ships bash) and prevents > future providers from declaring a different default — there's no way for a > provider to say "I have bash, use it" > - This PR adds a `shellCommand` field to sandbox execution targets so providers > can declare their preferred shell ("bash" for E2B), threads it through the > sandbox-managed-runtime client, callback bridge, and execution-target shell > helper, and validates the value at the lease-metadata boundary > - The benefit is that sandbox commands run under the right shell on the right > provider, and adding new sandbox providers only needs to declare a shell > preference ## What Changed - Added `packages/adapter-utils/src/sandbox-shell.ts` exporting `preferredShellForSandbox(shellCommand)` (returns `"bash"` if input is `"bash"`, else `"sh"`) - Added `shellCommand?: "bash" | "sh" | null` to `AdapterSandboxExecutionTarget` and `CommandManagedRuntimeSpec`; threaded it through `runAdapterExecutionTargetShellCommand`, `prepareAdapterExecutionTargetRuntime`, and `startAdapterExecutionTargetPaperclipBridge` - `createCommandManagedRuntimeClient`, `prepareCommandManagedRuntime`, and `createCommandManagedSandboxCallbackBridgeQueueClient` now take an optional `shellCommand` and use `preferredShellForSandbox` to pick the shell - `startSandboxCallbackBridgeServer` accepts a `shellCommand` for its server startup, readiness probe, and stop hook - E2B sandbox plugin declares `shellCommand: "bash"` in `leaseMetadata` - `resolveEnvironmentExecutionTarget` reads `shellCommand` from lease metadata (validating against `"bash" | "sh" | null`) - `environment-runtime.ts` adds `"shellCommand"` to `INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS` so the field round-trips through internal plugin config without leaking to external plugin metadata - Updated tests in `command-managed-runtime.test.ts`, `execution-target-sandbox.test.ts`, `sandbox-callback-bridge.test.ts`, `environment-execution-target.test.ts` ## Verification - `pnpm --filter @paperclipai/adapter-utils test` - `pnpm --filter @paperclipai/server test -- environment-execution-target` - `pnpm --filter @paperclipai/sandbox-providers-e2b test` - Manual QA: boot a Paperclip instance, create an E2B-backed environment, run a claude_local agent against it, and confirm the run completes (verifies bash shell semantics flow through the callback bridge end-to-end) ## Risks - E2B sandbox commands now run under `bash -lc` instead of `sh -lc`. Bash is a strict superset for the commands we issue (no busybox-only flags in our shell scripts), so risk is low. The shellCommand field is opt-in via lease metadata — providers that don't declare it stay on `sh`. - New optional field on `CommandManagedRuntimeSpec` and `AdapterSandboxExecutionTarget`. Consumers ignoring the field retain previous behaviour (sh). - Lease metadata now carries an additional field. Existing leases without `shellCommand` resolve to `null` and fall back to sh — backwards compatible. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A (no UI changes) - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/command-managed-runtime.test.ts | 10 ++++- .../src/command-managed-runtime.ts | 14 +++++-- .../src/execution-target-sandbox.test.ts | 38 ++++++++++++++++++- .../adapter-utils/src/execution-target.ts | 12 +++++- .../src/sandbox-callback-bridge.test.ts | 10 ++++- .../src/sandbox-callback-bridge.ts | 17 +++++++-- packages/adapter-utils/src/sandbox-shell.ts | 3 ++ .../sandbox-providers/e2b/src/plugin.ts | 1 + .../environment-execution-target.test.ts | 36 ++++++++++++++++++ .../services/environment-execution-target.ts | 5 +++ server/src/services/environment-runtime.ts | 1 + 11 files changed, 133 insertions(+), 14 deletions(-) create mode 100644 packages/adapter-utils/src/sandbox-shell.ts diff --git a/packages/adapter-utils/src/command-managed-runtime.test.ts b/packages/adapter-utils/src/command-managed-runtime.test.ts index 9be9c062d2f..f8c0dc7b795 100644 --- a/packages/adapter-utils/src/command-managed-runtime.test.ts +++ b/packages/adapter-utils/src/command-managed-runtime.test.ts @@ -55,9 +55,15 @@ describe("command managed runtime", () => { ...process.env, ...input.env, }; - const command = input.command === "sh" ? "/bin/sh" : input.command; + const command = + input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command; const args = [...(input.args ?? [])]; - if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") { + if ( + input.stdin != null && + (input.command === "sh" || input.command === "bash") && + args[0] === "-lc" && + typeof args[1] === "string" + ) { env.PAPERCLIP_TEST_STDIN = input.stdin; args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`; } diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index 706c3fd79dd..a126ffa486e 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -6,6 +6,7 @@ import { type SandboxManagedRuntimeClient, type SandboxRemoteExecutionSpec, } from "./sandbox-managed-runtime.js"; +import { preferredShellForSandbox } from "./sandbox-shell.js"; import type { RunProcessResult } from "./server-utils.js"; export interface CommandManagedRuntimeRunner { @@ -23,6 +24,7 @@ export interface CommandManagedRuntimeRunner { export interface CommandManagedRuntimeSpec { providerKey?: string | null; + shellCommand?: "bash" | "sh" | null; leaseId?: string | null; remoteCwd: string; timeoutMs?: number | null; @@ -58,10 +60,12 @@ export function createCommandManagedRuntimeClient(input: { runner: CommandManagedRuntimeRunner; remoteCwd: string; timeoutMs: number; + shellCommand?: "bash" | "sh" | null; }): SandboxManagedRuntimeClient { + const shellCommand = preferredShellForSandbox(input.shellCommand); const runShell = async (script: string, opts: { stdin?: string; timeoutMs?: number } = {}) => { const result = await input.runner.execute({ - command: "sh", + command: shellCommand, args: ["-lc", script], cwd: input.remoteCwd, stdin: opts.stdin, @@ -112,7 +116,7 @@ export function createCommandManagedRuntimeClient(input: { }, remove: async (remotePath) => { const result = await input.runner.execute({ - command: "sh", + command: shellCommand, args: ["-lc", `rm -rf ${shellQuote(remotePath)}`], cwd: input.remoteCwd, timeoutMs: input.timeoutMs, @@ -121,7 +125,7 @@ export function createCommandManagedRuntimeClient(input: { }, run: async (command, options) => { const result = await input.runner.execute({ - command: "sh", + command: shellCommand, args: ["-lc", command], cwd: input.remoteCwd, timeoutMs: options.timeoutMs, @@ -157,11 +161,13 @@ export async function prepareCommandManagedRuntime(input: { runner: input.runner, remoteCwd: workspaceRemoteDir, timeoutMs, + shellCommand: input.spec.shellCommand, }); + const shellCommand = preferredShellForSandbox(input.spec.shellCommand); if (input.installCommand?.trim()) { const result = await input.runner.execute({ - command: "sh", + command: shellCommand, args: ["-lc", input.installCommand.trim()], cwd: workspaceRemoteDir, timeoutMs, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index cda633544ca..6fe99a7c970 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -39,7 +39,8 @@ describe("sandbox adapter execution targets", () => { onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; }) => { counter += 1; - return runChildProcess(`sandbox-run-${counter}`, input.command, input.args ?? [], { + const command = input.command === "bash" ? "/bin/bash" : input.command; + return runChildProcess(`sandbox-run-${counter}`, command, input.args ?? [], { cwd: input.cwd ?? process.cwd(), env: input.env ?? {}, stdin: input.stdin, @@ -140,6 +141,41 @@ describe("sandbox adapter execution targets", () => { })); }); + it("uses the provider-declared shell for sandbox helper commands", async () => { + const runner = { + execute: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "/home/sandbox", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + })), + }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", + transport: "sandbox", + providerKey: "custom-provider", + shellCommand: "bash", + remoteCwd: "/workspace", + runner, + }; + + await runAdapterExecutionTargetShellCommand("run-2b", target, 'printf %s "$HOME"', { + cwd: "/local/workspace", + env: {}, + timeoutSec: 7, + }); + + expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "bash", + args: ["-lc", 'printf %s "$HOME"'], + cwd: "/workspace", + timeoutMs: 7000, + })); + }); + it("starts a localhost Paperclip bridge for sandbox targets in bridge mode", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-execution-target-bridge-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 58c128e80e3..8fe6ee4bd69 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -26,6 +26,7 @@ import { type RunProcessResult, type TerminalResultCleanupOptions, } from "./server-utils.js"; +import { preferredShellForSandbox } from "./sandbox-shell.js"; export interface AdapterLocalExecutionTarget { kind: "local"; @@ -47,6 +48,7 @@ export interface AdapterSandboxExecutionTarget { kind: "remote"; transport: "sandbox"; providerKey?: string | null; + shellCommand?: "bash" | "sh" | null; environmentId?: string | null; leaseId?: string | null; remoteCwd: string; @@ -214,6 +216,10 @@ function requireSandboxRunner(target: AdapterSandboxExecutionTarget): CommandMan ); } +function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | "sh" { + return preferredShellForSandbox(target.shellCommand); +} + export async function ensureAdapterExecutionTargetCommandResolvable( command: string, target: AdapterExecutionTarget | null | undefined, @@ -341,8 +347,9 @@ export async function runAdapterExecutionTargetShellCommand( } } + const shellCommand = preferredSandboxShell(target); return await requireSandboxRunner(target).execute({ - command: "sh", + command: shellCommand, args: ["-lc", command], cwd: target.remoteCwd, env: options.env, @@ -612,6 +619,7 @@ export async function prepareAdapterExecutionTargetRuntime(input: { runner: requireSandboxRunner(target), spec: { providerKey: target.providerKey, + shellCommand: target.shellCommand, leaseId: target.leaseId, remoteCwd: target.remoteCwd, timeoutMs: target.timeoutMs, @@ -745,6 +753,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { runner: requireSandboxRunner(target), remoteCwd: target.remoteCwd, timeoutMs: target.timeoutMs, + shellCommand: preferredSandboxShell(target), }); worker = await startSandboxCallbackBridgeWorker({ client, @@ -781,6 +790,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { bridgeAsset, timeoutMs: target.timeoutMs, maxBodyBytes, + shellCommand: preferredSandboxShell(target), }); } catch (error) { await Promise.allSettled([ diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index d036771d302..dc04df6c5dc 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -37,9 +37,15 @@ describe("sandbox callback bridge", () => { ...process.env, ...input.env, }; - const command = input.command === "sh" ? "/bin/sh" : input.command; + const command = + input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command; const args = [...(input.args ?? [])]; - if (input.stdin != null && input.command === "sh" && args[0] === "-lc" && typeof args[1] === "string") { + if ( + input.stdin != null && + (input.command === "sh" || input.command === "bash") && + args[0] === "-lc" && + typeof args[1] === "string" + ) { env.PAPERCLIP_TEST_STDIN = input.stdin; args[1] = `printf '%s' \"$PAPERCLIP_TEST_STDIN\" | (${args[1]})`; } diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 013a3bbb6ae..666ba9e925e 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; +import { preferredShellForSandbox } from "./sandbox-shell.js"; import type { RunProcessResult } from "./server-utils.js"; const DEFAULT_BRIDGE_TOKEN_BYTES = 24; @@ -133,9 +134,10 @@ async function runShell( cwd: string, script: string, timeoutMs: number, + shellCommand: "bash" | "sh" = "sh", ): Promise { return await runner.execute({ - command: "sh", + command: shellCommand, args: ["-lc", script], cwd, timeoutMs, @@ -266,10 +268,12 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: { runner: CommandManagedRuntimeRunner; remoteCwd: string; timeoutMs?: number | null; + shellCommand?: "bash" | "sh" | null; }): SandboxCallbackBridgeQueueClient { const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS); + const shellCommand = preferredShellForSandbox(input.shellCommand); const runChecked = async (action: string, script: string) => - requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs)); + requireSuccessfulResult(action, await runShell(input.runner, input.remoteCwd, script, timeoutMs, shellCommand)); return { makeDir: async (remotePath) => { @@ -288,6 +292,7 @@ export function createCommandManagedSandboxCallbackBridgeQueueClient(input: { "fi", ].join("\n"), timeoutMs, + shellCommand, ); requireSuccessfulResult(`list ${remotePath}`, result); return result.stdout @@ -525,10 +530,12 @@ export async function startSandboxCallbackBridgeServer(input: { responseTimeoutMs?: number | null; timeoutMs?: number | null; nodeCommand?: string; + shellCommand?: "bash" | "sh" | null; maxQueueDepth?: number | null; maxBodyBytes?: number | null; }): Promise { const timeoutMs = normalizeTimeoutMs(input.timeoutMs, DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS); + const shellCommand = preferredShellForSandbox(input.shellCommand); const directories = sandboxCallbackBridgeDirectories(input.queueDir); const remoteEntrypoint = path.posix.join(input.assetRemoteDir, SANDBOX_CALLBACK_BRIDGE_ENTRYPOINT); if (input.bridgeAsset) { @@ -536,6 +543,7 @@ export async function startSandboxCallbackBridgeServer(input: { runner: input.runner, remoteCwd: input.remoteCwd, timeoutMs, + shellCommand, }); await assetClient.makeDir(input.assetRemoteDir); const entrypointSource = await fs.readFile(input.bridgeAsset.entrypoint, "utf8"); @@ -553,7 +561,7 @@ export async function startSandboxCallbackBridgeServer(input: { }); const nodeCommand = input.nodeCommand?.trim() || "node"; const startResult = await input.runner.execute({ - command: "sh", + command: shellCommand, args: [ "-lc", [ @@ -594,6 +602,7 @@ export async function startSandboxCallbackBridgeServer(input: { "exit 1", ].join("\n"), timeoutMs, + shellCommand, ); requireSuccessfulResult("wait for sandbox callback bridge readiness", readyResult); @@ -626,7 +635,7 @@ export async function startSandboxCallbackBridgeServer(input: { directories, stop: async () => { const stopResult = await input.runner.execute({ - command: "sh", + command: shellCommand, args: [ "-lc", [ diff --git a/packages/adapter-utils/src/sandbox-shell.ts b/packages/adapter-utils/src/sandbox-shell.ts new file mode 100644 index 00000000000..c83c0a1a2fa --- /dev/null +++ b/packages/adapter-utils/src/sandbox-shell.ts @@ -0,0 +1,3 @@ +export function preferredShellForSandbox(shellCommand: string | null | undefined): "bash" | "sh" { + return shellCommand === "bash" ? "bash" : "sh"; +} diff --git a/packages/plugins/sandbox-providers/e2b/src/plugin.ts b/packages/plugins/sandbox-providers/e2b/src/plugin.ts index 3504620f8e8..723cc8109bc 100644 --- a/packages/plugins/sandbox-providers/e2b/src/plugin.ts +++ b/packages/plugins/sandbox-providers/e2b/src/plugin.ts @@ -127,6 +127,7 @@ function leaseMetadata(input: { }) { return { provider: "e2b", + shellCommand: "bash", template: input.config.template, timeoutMs: input.config.timeoutMs, reuseLease: input.config.reuseLease, diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index cd07ee9ca07..a1a5ab1d858 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -97,4 +97,40 @@ describe("resolveEnvironmentExecutionTarget", () => { paperclipTransport: "direct", }); }); + + it("passes through a provider-declared sandbox shell command from lease metadata", async () => { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "sandbox", + config: { + provider: "fake-plugin", + reuseLease: false, + timeoutMs: 30_000, + }, + }); + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "claude_local", + environment: { + id: "env-1", + driver: "sandbox", + config: { + provider: "fake-plugin", + }, + }, + leaseId: "lease-1", + leaseMetadata: { + shellCommand: "bash", + }, + lease: null, + environmentRuntime: null, + }); + + expect(target).toMatchObject({ + kind: "remote", + transport: "sandbox", + shellCommand: "bash", + }); + }); }); diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index 0f2f70db53f..c2f2e4fb8fd 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -62,11 +62,16 @@ export async function resolveEnvironmentExecutionTarget(input: { typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0 ? input.leaseMetadata.paperclipApiUrl.trim() : null; + const shellCommand = + input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh" + ? input.leaseMetadata.shellCommand + : null; return { kind: "remote", transport: "sandbox", providerKey: parsed.config.provider, + shellCommand, remoteCwd, environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index ec3c9496551..d83244ab16e 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -726,6 +726,7 @@ const INTERNAL_PLUGIN_SANDBOX_CONFIG_KEYS = new Set([ "pluginId", "pluginKey", "providerMetadata", + "shellCommand", "sandboxProviderPlugin", ]); From 076067865f9922b7e618344e7c52bcacb39b7b59 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 12:43:52 -0700 Subject: [PATCH 03/27] Migrate SSH environment callback to bridge (#5116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 3 of 7).** Depends on: - PR #5114 - PR #5115 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents executing on a remote SSH-backed environment need a way to call back into > the Paperclip control plane (run events, log streaming, signals) > - When the SSH host can't reach the Paperclip host (NAT, firewalls, or simply not > on the same network), the run silently fails or hangs — a recurring class of > failure during SSH testing > - In sandboxed environments we already solved this with a callback bridge that > tunnels back through the existing connection; SSH was the odd one out > - This PR migrates SSH execution to use the same callback bridge, so every > adapter's remote run uses one consistent reverse-channel. Per-adapter SSH glue > is deleted in favour of a shared `CommandManagedRuntimeRunner` built from the > SSH spec > - The benefit is fewer SSH-specific failure modes, a smaller code surface, and > one place to evolve the callback contract going forward ## What Changed - Added `createSshCommandManagedRuntimeRunner` in `packages/adapter-utils/src/ssh.ts` that adapts an SSH spec into a generic command-managed-runtime runner (with cwd, env, and timeout handling) - Removed `paperclipApiUrl` from `SshRemoteExecutionSpec`; the bridge URL now flows through the shared runner - Reworked `execution-target.ts` to use the SSH runner alongside sandbox runners via a unified `CommandManagedRuntimeRunner` interface - Simplified `remote-managed-runtime.ts` and `sandbox-managed-runtime.ts` to consume the shared runner abstraction - Deleted per-adapter SSH callback wiring from claude-local, codex-local, cursor-local, gemini-local, opencode-local, pi-local execute.ts files - Removed `environment-runtime-driver-contract.test.ts` (the contract is now enforced by `environment-execution-target.test.ts`) - Added/updated `execute.remote.test.ts` cases for each adapter to cover the SSH runner path ## Verification - `pnpm --filter @paperclipai/adapter-utils test` - `pnpm test -- execute.remote` (covers all six local adapters' SSH paths) - Manual QA: ran a claude-local agent against an SSH-backed environment, confirmed the agent successfully called back to `/api/agent-callback/*` endpoints during the run ## Risks - Refactor touches all six local adapters. If any adapter had subtle SSH-specific behaviour that wasn't captured in tests, it could regress. Mitigation: each adapter's `execute.remote.test.ts` was extended. - `paperclipApiUrl` removal from `SshRemoteExecutionSpec` is a breaking type change for any internal consumer. Verified no external plugins consume this type. - The new `CommandManagedRuntimeRunner` shape is a public surface in `@paperclipai/adapter-utils`; downstream plugins implementing custom runners may need updates, but no such plugins exist in this repo. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/command-managed-runtime.ts | 2 - .../src/execution-target-sandbox.test.ts | 31 ++++- .../adapter-utils/src/execution-target.ts | 82 +++++------ .../src/remote-managed-runtime.ts | 4 +- .../src/sandbox-managed-runtime.ts | 6 +- packages/adapter-utils/src/ssh.ts | 131 +++++++++++------- .../src/server/execute.remote.test.ts | 24 +++- .../claude-local/src/server/execute.ts | 6 - .../src/server/execute.remote.test.ts | 22 +++ .../codex-local/src/server/execute.ts | 5 - .../src/server/execute.remote.test.ts | 25 +++- .../cursor-local/src/server/execute.ts | 5 - .../src/server/execute.remote.test.ts | 25 +++- .../gemini-local/src/server/execute.ts | 4 - .../src/server/execute.remote.test.ts | 25 +++- .../opencode-local/src/server/execute.ts | 4 - .../src/server/execute.remote.test.ts | 25 +++- .../adapters/pi-local/src/server/execute.ts | 4 - .../environment-execution-target.test.ts | 57 +++++++- ...nvironment-runtime-driver-contract.test.ts | 37 ----- .../src/__tests__/environment-runtime.test.ts | 28 ---- .../services/environment-execution-target.ts | 14 -- server/src/services/environment-runtime.ts | 24 +--- 23 files changed, 331 insertions(+), 259 deletions(-) diff --git a/packages/adapter-utils/src/command-managed-runtime.ts b/packages/adapter-utils/src/command-managed-runtime.ts index a126ffa486e..844fc31d3b5 100644 --- a/packages/adapter-utils/src/command-managed-runtime.ts +++ b/packages/adapter-utils/src/command-managed-runtime.ts @@ -28,7 +28,6 @@ export interface CommandManagedRuntimeSpec { leaseId?: string | null; remoteCwd: string; timeoutMs?: number | null; - paperclipApiUrl?: string | null; } export type CommandManagedRuntimeAsset = SandboxManagedRuntimeAsset; @@ -155,7 +154,6 @@ export async function prepareCommandManagedRuntime(input: { remoteCwd: workspaceRemoteDir, timeoutMs, apiKey: null, - paperclipApiUrl: input.spec.paperclipApiUrl ?? null, }; const client = createCommandManagedRuntimeClient({ runner: input.runner, diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index 6fe99a7c970..e0ebb37cc50 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { adapterExecutionTargetSessionIdentity, adapterExecutionTargetToRemoteSpec, + adapterExecutionTargetUsesPaperclipBridge, runAdapterExecutionTargetProcess, runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, @@ -104,7 +105,6 @@ describe("sandbox adapter execution targets", () => { environmentId: "env-1", leaseId: "lease-1", remoteCwd: "/workspace", - paperclipTransport: "bridge", }); }); @@ -141,6 +141,33 @@ describe("sandbox adapter execution targets", () => { })); }); + it("treats SSH targets as bridge-only", () => { + const target = { + kind: "remote" as const, + transport: "ssh" as const, + remoteCwd: "/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteWorkspacePath: "/workspace", + remoteCwd: "/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }; + + expect(adapterExecutionTargetUsesPaperclipBridge(target)).toBe(true); + expect(adapterExecutionTargetSessionIdentity(target)).toEqual({ + transport: "ssh", + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteCwd: "/workspace", + }); + }); + it("uses the provider-declared shell for sandbox helper commands", async () => { const runner = { execute: vi.fn(async () => ({ @@ -210,7 +237,6 @@ describe("sandbox adapter execution targets", () => { environmentId: "env-1", leaseId: "lease-1", remoteCwd, - paperclipTransport: "bridge", runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; @@ -288,7 +314,6 @@ describe("sandbox adapter execution targets", () => { environmentId: "env-1", leaseId: "lease-1", remoteCwd, - paperclipTransport: "bridge", runner: createLocalSandboxRunner(), timeoutMs: 30_000, }; diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 8fe6ee4bd69..e0b02730179 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -18,7 +18,7 @@ import { startSandboxCallbackBridgeServer, startSandboxCallbackBridgeWorker, } from "./sandbox-callback-bridge.js"; -import { parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; +import { createSshCommandManagedRuntimeRunner, parseSshRemoteExecutionSpec, runSshCommand, shellQuote } from "./ssh.js"; import { ensureCommandResolvable, resolveCommandForLogs, @@ -40,7 +40,6 @@ export interface AdapterSshExecutionTarget { environmentId?: string | null; leaseId?: string | null; remoteCwd: string; - paperclipApiUrl?: string | null; spec: SshRemoteExecutionSpec; } @@ -52,8 +51,6 @@ export interface AdapterSandboxExecutionTarget { environmentId?: string | null; leaseId?: string | null; remoteCwd: string; - paperclipApiUrl?: string | null; - paperclipTransport?: "direct" | "bridge"; timeoutMs?: number | null; runner?: CommandManagedRuntimeRunner; } @@ -128,15 +125,6 @@ function resolveDefaultPaperclipApiUrl(): string { return `http://${runtimeHost}:${runtimePort}`; } -function resolveSandboxPaperclipTransport( - target: Pick, -): "direct" | "bridge" { - if (target.paperclipTransport === "direct" || target.paperclipTransport === "bridge") { - return target.paperclipTransport; - } - return target.paperclipApiUrl ? "direct" : "bridge"; -} - function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget { const parsed = parseObject(value); if (parsed.kind === "local") return true; @@ -182,21 +170,10 @@ export function resolveAdapterExecutionTargetCwd( return adapterExecutionTargetRemoteCwd(target, localFallbackCwd); } -export function adapterExecutionTargetPaperclipApiUrl( - target: AdapterExecutionTarget | null | undefined, -): string | null { - if (target?.kind !== "remote") return null; - if (target.transport === "ssh") return target.paperclipApiUrl ?? target.spec.paperclipApiUrl ?? null; - if (resolveSandboxPaperclipTransport(target) === "bridge") return null; - return target.paperclipApiUrl ?? null; -} - export function adapterExecutionTargetUsesPaperclipBridge( target: AdapterExecutionTarget | null | undefined, ): boolean { - return target?.kind === "remote" && - target.transport === "sandbox" && - resolveSandboxPaperclipTransport(target) === "bridge"; + return target?.kind === "remote"; } export function describeAdapterExecutionTarget( @@ -220,6 +197,29 @@ function preferredSandboxShell(target: AdapterSandboxExecutionTarget): "bash" | return preferredShellForSandbox(target.shellCommand); } +type AdapterCommandCapableExecutionTarget = AdapterSshExecutionTarget | AdapterSandboxExecutionTarget; + +function adapterExecutionTargetCommandRunner(target: AdapterCommandCapableExecutionTarget): CommandManagedRuntimeRunner { + if (target.transport === "ssh") { + return createSshCommandManagedRuntimeRunner({ + spec: target.spec, + defaultCwd: target.remoteCwd, + maxBufferBytes: DEFAULT_SANDBOX_CALLBACK_BRIDGE_MAX_BODY_BYTES * 4, + }); + } + return requireSandboxRunner(target); +} + +function adapterExecutionTargetShellCommand(target: AdapterCommandCapableExecutionTarget): "bash" | "sh" { + return target.transport === "ssh" ? "sh" : preferredSandboxShell(target); +} + +function adapterExecutionTargetTimeoutMs( + target: AdapterCommandCapableExecutionTarget, +): number | null | undefined { + return target.transport === "sandbox" ? target.timeoutMs : undefined; +} + export async function ensureAdapterExecutionTargetCommandResolvable( command: string, target: AdapterExecutionTarget | null | undefined, @@ -465,15 +465,12 @@ export function adapterExecutionTargetSessionIdentity( ): Record | null { if (!target || target.kind === "local") return null; if (target.transport === "ssh") return buildRemoteExecutionSessionIdentity(target.spec); - const paperclipTransport = resolveSandboxPaperclipTransport(target); return { transport: "sandbox", providerKey: target.providerKey ?? null, environmentId: target.environmentId ?? null, leaseId: target.leaseId ?? null, remoteCwd: target.remoteCwd, - paperclipTransport, - ...(paperclipTransport === "direct" && target.paperclipApiUrl ? { paperclipApiUrl: target.paperclipApiUrl } : {}), }; } @@ -492,9 +489,7 @@ export function adapterExecutionTargetSessionMatches( readStringMeta(parsedSaved, "providerKey") === current?.providerKey && readStringMeta(parsedSaved, "environmentId") === current?.environmentId && readStringMeta(parsedSaved, "leaseId") === current?.leaseId && - readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd && - readStringMeta(parsedSaved, "paperclipTransport") === (current?.paperclipTransport ?? null) && - readStringMeta(parsedSaved, "paperclipApiUrl") === (current?.paperclipApiUrl ?? null) + readStringMeta(parsedSaved, "remoteCwd") === current?.remoteCwd ); } @@ -519,14 +514,12 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), remoteCwd: spec.remoteCwd, - paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl") ?? spec.paperclipApiUrl ?? null, spec, }; } if (kind === "remote" && readStringMeta(parsed, "transport") === "sandbox") { const remoteCwd = readStringMeta(parsed, "remoteCwd"); - const paperclipTransport = readStringMeta(parsed, "paperclipTransport"); if (!remoteCwd) return null; return { kind: "remote", @@ -535,11 +528,6 @@ export function parseAdapterExecutionTarget(value: unknown): AdapterExecutionTar environmentId: readStringMeta(parsed, "environmentId"), leaseId: readStringMeta(parsed, "leaseId"), remoteCwd, - paperclipApiUrl: readStringMeta(parsed, "paperclipApiUrl"), - paperclipTransport: - paperclipTransport === "direct" || paperclipTransport === "bridge" - ? paperclipTransport - : undefined, timeoutMs: typeof parsed.timeoutMs === "number" ? parsed.timeoutMs : null, }; } @@ -560,7 +548,6 @@ export function adapterExecutionTargetFromRemoteExecution( environmentId: metadata.environmentId ?? null, leaseId: metadata.leaseId ?? null, remoteCwd: ssh.remoteCwd, - paperclipApiUrl: ssh.paperclipApiUrl ?? null, spec: ssh, }; } @@ -623,7 +610,6 @@ export async function prepareAdapterExecutionTargetRuntime(input: { leaseId: target.leaseId, remoteCwd: target.remoteCwd, timeoutMs: target.timeoutMs, - paperclipApiUrl: target.paperclipApiUrl, }, adapterKey: input.adapterKey, workspaceLocalDir: input.workspaceLocalDir, @@ -711,7 +697,7 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { if (!adapterExecutionTargetUsesPaperclipBridge(input.target)) { return null; } - if (!input.target || input.target.kind !== "remote" || input.target.transport !== "sandbox") { + if (!input.target || input.target.kind !== "remote") { return null; } @@ -739,6 +725,8 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { process.env.PAPERCLIP_RUNTIME_API_URL?.trim() || process.env.PAPERCLIP_API_URL?.trim() || resolveDefaultPaperclipApiUrl(); + const shellCommand = adapterExecutionTargetShellCommand(target); + const runner = adapterExecutionTargetCommandRunner(target); await onLog( "stdout", @@ -750,10 +738,10 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { let worker: Awaited> | null = null; try { const client = createCommandManagedSandboxCallbackBridgeQueueClient({ - runner: requireSandboxRunner(target), + runner, remoteCwd: target.remoteCwd, - timeoutMs: target.timeoutMs, - shellCommand: preferredSandboxShell(target), + timeoutMs: adapterExecutionTargetTimeoutMs(target), + shellCommand, }); worker = await startSandboxCallbackBridgeWorker({ client, @@ -782,15 +770,15 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { }, }); server = await startSandboxCallbackBridgeServer({ - runner: requireSandboxRunner(target), + runner, remoteCwd: target.remoteCwd, assetRemoteDir, queueDir, bridgeToken, bridgeAsset, - timeoutMs: target.timeoutMs, + timeoutMs: adapterExecutionTargetTimeoutMs(target), maxBodyBytes, - shellCommand: preferredSandboxShell(target), + shellCommand, }); } catch (error) { await Promise.allSettled([ diff --git a/packages/adapter-utils/src/remote-managed-runtime.ts b/packages/adapter-utils/src/remote-managed-runtime.ts index 089ff46aba2..18abdcf964c 100644 --- a/packages/adapter-utils/src/remote-managed-runtime.ts +++ b/packages/adapter-utils/src/remote-managed-runtime.ts @@ -44,7 +44,6 @@ export function buildRemoteExecutionSessionIdentity(spec: SshRemoteExecutionSpec port: spec.port, username: spec.username, remoteCwd: spec.remoteCwd, - ...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}), } as const; } @@ -58,8 +57,7 @@ export function remoteExecutionSessionMatches(saved: unknown, current: SshRemote asString(parsedSaved.host) === currentIdentity.host && asNumber(parsedSaved.port) === currentIdentity.port && asString(parsedSaved.username) === currentIdentity.username && - asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd && - asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl) + asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd ); } diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 518cc80a17b..762c5971e2b 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -13,7 +13,6 @@ export interface SandboxRemoteExecutionSpec { remoteCwd: string; timeoutMs: number; apiKey: string | null; - paperclipApiUrl?: string | null; } export interface SandboxManagedRuntimeAsset { @@ -85,7 +84,6 @@ export function parseSandboxRemoteExecutionSpec(value: unknown): SandboxRemoteEx remoteCwd, timeoutMs, apiKey: asString(parsed.apiKey).trim() || null, - paperclipApiUrl: asString(parsed.paperclipApiUrl).trim() || null, }; } @@ -96,7 +94,6 @@ export function buildSandboxExecutionSessionIdentity(spec: SandboxRemoteExecutio provider: spec.provider, sandboxId: spec.sandboxId, remoteCwd: spec.remoteCwd, - ...(spec.paperclipApiUrl ? { paperclipApiUrl: spec.paperclipApiUrl } : {}), } as const; } @@ -108,8 +105,7 @@ export function sandboxExecutionSessionMatches(saved: unknown, current: SandboxR asString(parsedSaved.transport) === currentIdentity.transport && asString(parsedSaved.provider) === currentIdentity.provider && asString(parsedSaved.sandboxId) === currentIdentity.sandboxId && - asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd && - asString(parsedSaved.paperclipApiUrl) === asString(currentIdentity.paperclipApiUrl) + asString(parsedSaved.remoteCwd) === currentIdentity.remoteCwd ); } diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 2e41829ce08..39087b3e608 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -3,6 +3,8 @@ import { constants as fsConstants, createReadStream, createWriteStream, promises import net from "node:net"; import os from "node:os"; import path from "node:path"; +import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; +import type { RunProcessResult } from "./server-utils.js"; export interface SshConnectionConfig { host: string; @@ -21,7 +23,86 @@ export interface SshCommandResult { export interface SshRemoteExecutionSpec extends SshConnectionConfig { remoteCwd: string; - paperclipApiUrl?: string | null; +} + +export function createSshCommandManagedRuntimeRunner(input: { + spec: SshRemoteExecutionSpec; + defaultCwd?: string | null; + maxBufferBytes?: number | null; +}): CommandManagedRuntimeRunner { + const defaultCwd = input.defaultCwd?.trim() || input.spec.remoteCwd; + const maxBufferBytes = + typeof input.maxBufferBytes === "number" && Number.isFinite(input.maxBufferBytes) && input.maxBufferBytes > 0 + ? Math.trunc(input.maxBufferBytes) + : 1024 * 1024; + + return { + execute: async (commandInput): Promise => { + const startedAt = new Date().toISOString(); + const command = commandInput.command.trim(); + const args = commandInput.args ?? []; + const cwd = commandInput.cwd?.trim() || defaultCwd; + const envEntries = Object.entries(commandInput.env ?? {}) + .filter((entry): entry is [string, string] => typeof entry[1] === "string"); + const envPrefix = envEntries.length > 0 + ? `env ${envEntries.map(([key, value]) => `${key}=${shellQuote(value)}`).join(" ")} ` + : ""; + const exportPrefix = envEntries.length > 0 + ? envEntries.map(([key, value]) => `export ${key}=${shellQuote(value)};`).join(" ") + " " + : ""; + const commandScript = command === "sh" || command === "bash" + ? args[0] === "-lc" && typeof args[1] === "string" + ? `${exportPrefix}${args[1]}` + : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}` + : `${envPrefix}exec ${[shellQuote(command), ...args.map((arg) => shellQuote(arg))].join(" ")}`; + const remoteCommand = `${command === "bash" ? "bash" : "sh"} -lc ${ + shellQuote(`cd ${shellQuote(cwd)} && ${commandScript}`) + }`; + + try { + const result = await runSshCommand(input.spec, remoteCommand, { + timeoutMs: commandInput.timeoutMs, + maxBuffer: maxBufferBytes, + }); + if (result.stdout) await commandInput.onLog?.("stdout", result.stdout); + if (result.stderr) await commandInput.onLog?.("stderr", result.stderr); + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: result.stdout, + stderr: result.stderr, + pid: null, + startedAt, + }; + } catch (error) { + const failure = error as { + stdout?: unknown; + stderr?: unknown; + code?: unknown; + signal?: unknown; + killed?: unknown; + }; + const stdout = typeof failure.stdout === "string" ? failure.stdout : ""; + const stderr = typeof failure.stderr === "string" + ? failure.stderr + : error instanceof Error + ? error.message + : String(error); + if (stdout) await commandInput.onLog?.("stdout", stdout); + if (stderr) await commandInput.onLog?.("stderr", stderr); + return { + exitCode: typeof failure.code === "number" ? failure.code : null, + signal: typeof failure.signal === "string" ? failure.signal : null, + timedOut: failure.killed === true, + stdout, + stderr, + pid: null, + startedAt, + }; + } + }, + }; } export interface SshEnvLabSupport { @@ -83,10 +164,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS port: portValue, username, remoteCwd, - paperclipApiUrl: - typeof parsed.paperclipApiUrl === "string" && parsed.paperclipApiUrl.trim().length > 0 - ? parsed.paperclipApiUrl.trim() - : null, remoteWorkspacePath: typeof parsed.remoteWorkspacePath === "string" && parsed.remoteWorkspacePath.trim().length > 0 ? parsed.remoteWorkspacePath.trim() @@ -98,50 +175,6 @@ export function parseSshRemoteExecutionSpec(value: unknown): SshRemoteExecutionS }; } -function normalizeHttpUrlCandidate(value: string): string | null { - const trimmed = value.trim(); - if (!trimmed) return null; - try { - const parsed = new URL(trimmed); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return null; - } - return parsed.origin; - } catch { - return null; - } -} - -export async function findReachablePaperclipApiUrlOverSsh(input: { - config: SshConnectionConfig; - candidates: string[]; - timeoutMs?: number; -}): Promise { - const uniqueCandidates = Array.from( - new Set( - input.candidates - .map((candidate) => normalizeHttpUrlCandidate(candidate)) - .filter((candidate): candidate is string => candidate !== null), - ), - ); - - for (const candidate of uniqueCandidates) { - const healthUrl = new URL("/api/health", candidate).toString(); - try { - await runSshCommand( - input.config, - `sh -lc ${shellQuote(`curl -fsS -m ${Math.max(1, Math.ceil((input.timeoutMs ?? 5_000) / 1000))} ${shellQuote(healthUrl)} >/dev/null`)}`, - { timeoutMs: input.timeoutMs ?? 5_000 }, - ); - return candidate; - } catch { - continue; - } - } - - return null; -} - async function execFileText( file: string, args: string[], diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index c96b91643f1..e435ffc3577 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -10,6 +10,7 @@ const { prepareWorkspaceForSshExecution, restoreWorkspaceFromSshExecution, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -29,6 +30,14 @@ const { prepareWorkspaceForSshExecution: vi.fn(async () => undefined), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -55,6 +64,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("claude remote execution", () => { @@ -112,7 +131,6 @@ describe("claude remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -136,8 +154,10 @@ describe("claude remote execution", () => { expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md"); expect(call?.[2]).toContain("--add-dir"); expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills"); - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index e9ef11e2917..804a9c296e7 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -5,7 +5,6 @@ import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -222,11 +221,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise ({ runChildProcess: vi.fn(async () => ({ exitCode: 1, @@ -25,6 +26,14 @@ const { prepareWorkspaceForSshExecution: vi.fn(async () => undefined), restoreWorkspaceFromSshExecution: vi.fn(async () => undefined), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -51,6 +60,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("codex remote execution", () => { @@ -134,7 +153,10 @@ describe("codex remote execution", () => { | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(expect.objectContaining({ localDir: workspaceDir, diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index f4ca5c13a93..e2617e36d5f 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -448,10 +447,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -35,6 +36,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -62,6 +71,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("cursor remote execution", () => { @@ -116,7 +135,6 @@ describe("cursor remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -131,7 +149,6 @@ describe("cursor remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); @@ -150,8 +167,10 @@ describe("cursor remote execution", () => { | undefined; expect(call?.[2]).toContain("--workspace"); expect(call?.[2]).toContain("/remote/workspace"); - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 43de3644bc0..123b75bea0f 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -292,10 +291,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) { - env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } diff --git a/packages/adapters/gemini-local/src/server/execute.remote.test.ts b/packages/adapters/gemini-local/src/server/execute.remote.test.ts index 4c97e91513a..563d56dccd2 100644 --- a/packages/adapters/gemini-local/src/server/execute.remote.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -11,6 +11,7 @@ const { restoreWorkspaceFromSshExecution, runSshCommand, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -41,6 +42,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -68,6 +77,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("gemini remote execution", () => { @@ -122,7 +141,6 @@ describe("gemini remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -137,7 +155,6 @@ describe("gemini remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); @@ -154,8 +171,10 @@ describe("gemini remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index b9bb581cc23..4c7e82423eb 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -252,9 +251,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index bdf559915dd..21687730598 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -11,6 +11,7 @@ const { restoreWorkspaceFromSshExecution, runSshCommand, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -39,6 +40,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -66,6 +75,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("opencode remote execution", () => { @@ -121,7 +140,6 @@ describe("opencode remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -136,7 +154,6 @@ describe("opencode remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); @@ -156,9 +173,11 @@ describe("opencode remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 82f62f1d315..ca273ac8aef 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -211,9 +210,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts index 302f44410c0..c20df55bf96 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -11,6 +11,7 @@ const { restoreWorkspaceFromSshExecution, runSshCommand, syncDirectoryToSsh, + startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ runChildProcess: vi.fn(async () => ({ exitCode: 0, @@ -44,6 +45,14 @@ const { exitCode: 0, })), syncDirectoryToSsh: vi.fn(async () => undefined), + startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({ + env: { + PAPERCLIP_API_URL: "http://127.0.0.1:4310", + PAPERCLIP_API_KEY: "bridge-token", + PAPERCLIP_API_BRIDGE_MODE: "queue_v1", + }, + stop: async () => {}, + })), })); vi.mock("@paperclipai/adapter-utils/server-utils", async () => { @@ -71,6 +80,16 @@ vi.mock("@paperclipai/adapter-utils/ssh", async () => { }; }); +vi.mock("@paperclipai/adapter-utils/execution-target", async () => { + const actual = await vi.importActual( + "@paperclipai/adapter-utils/execution-target", + ); + return { + ...actual, + startAdapterExecutionTargetPaperclipBridge, + }; +}); + import { execute } from "./execute.js"; describe("pi remote execution", () => { @@ -126,7 +145,6 @@ describe("pi remote execution", () => { privateKey: "PRIVATE KEY", knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", strictHostKeyChecking: true, - paperclipApiUrl: "http://198.51.100.10:3102", }, }, onLog: async () => {}, @@ -140,7 +158,6 @@ describe("pi remote execution", () => { port: 2222, username: "fixture", remoteCwd: "/remote/workspace", - paperclipApiUrl: "http://198.51.100.10:3102", }, }); expect(String(result.sessionId)).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/"); @@ -161,8 +178,10 @@ describe("pi remote execution", () => { expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("--skill"); expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills"); - expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://198.51.100.10:3102"); + expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); + expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1); expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index ef7f654b8d3..827eccf75d4 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { adapterExecutionTargetIsRemote, - adapterExecutionTargetPaperclipApiUrl, adapterExecutionTargetRemoteCwd, adapterExecutionTargetSessionIdentity, adapterExecutionTargetSessionMatches, @@ -240,9 +239,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); - const targetPaperclipApiUrl = adapterExecutionTargetPaperclipApiUrl(executionTarget); - if (targetPaperclipApiUrl) env.PAPERCLIP_API_URL = targetPaperclipApiUrl; - for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } diff --git a/server/src/__tests__/environment-execution-target.test.ts b/server/src/__tests__/environment-execution-target.test.ts index a1a5ab1d858..6577abed163 100644 --- a/server/src/__tests__/environment-execution-target.test.ts +++ b/server/src/__tests__/environment-execution-target.test.ts @@ -54,14 +54,11 @@ describe("resolveEnvironmentExecutionTarget", () => { remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD, leaseId: "lease-1", environmentId: "env-1", - paperclipTransport: "bridge", timeoutMs: 30_000, }); }); - it("prefers an explicit Paperclip API URL from lease metadata for sandbox targets", async () => { - process.env.PAPERCLIP_API_URL = "https://paperclip.example.test"; - process.env.PAPERCLIP_RUNTIME_API_URL = "http://paperclip.example.test:3200"; + it("keeps sandbox targets on bridge mode even when lease metadata includes a Paperclip API URL", async () => { mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ driver: "sandbox", config: { @@ -93,9 +90,11 @@ describe("resolveEnvironmentExecutionTarget", () => { expect(target).toMatchObject({ kind: "remote", transport: "sandbox", - paperclipApiUrl: "https://paperclip.example.test", - paperclipTransport: "direct", + providerKey: "fake-plugin", + remoteCwd: DEFAULT_SANDBOX_REMOTE_CWD, }); + expect(target).not.toHaveProperty("paperclipApiUrl"); + expect(target).not.toHaveProperty("paperclipTransport"); }); it("passes through a provider-declared sandbox shell command from lease metadata", async () => { @@ -133,4 +132,50 @@ describe("resolveEnvironmentExecutionTarget", () => { shellCommand: "bash", }); }); + + it("resolves SSH execution targets in bridge mode", async () => { + mockResolveEnvironmentDriverConfigForRuntime.mockResolvedValue({ + driver: "ssh", + config: { + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteWorkspacePath: "/srv/paperclip", + privateKey: "PRIVATE KEY", + knownHosts: "[ssh.example.test]:22 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }); + + const target = await resolveEnvironmentExecutionTarget({ + db: {} as never, + companyId: "company-1", + adapterType: "codex_local", + environment: { + id: "env-ssh-1", + driver: "ssh", + config: {}, + }, + leaseId: "lease-ssh-1", + leaseMetadata: {}, + lease: null, + environmentRuntime: null, + }); + + expect(target).toMatchObject({ + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip", + leaseId: "lease-ssh-1", + environmentId: "env-ssh-1", + spec: { + host: "ssh.example.test", + port: 22, + username: "paperclip", + remoteWorkspacePath: "/srv/paperclip", + remoteCwd: "/srv/paperclip", + }, + }); + expect(target).not.toHaveProperty("paperclipApiUrl"); + }); }); diff --git a/server/src/__tests__/environment-runtime-driver-contract.test.ts b/server/src/__tests__/environment-runtime-driver-contract.test.ts index 482368ece2b..067c040d263 100644 --- a/server/src/__tests__/environment-runtime-driver-contract.test.ts +++ b/server/src/__tests__/environment-runtime-driver-contract.test.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { createServer, type Server } from "node:http"; import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -55,7 +54,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { let stopDb: (() => Promise) | null = null; let db!: ReturnType; const fixtureRoots: string[] = []; - const servers: Server[] = []; beforeAll(async () => { const started = await startEmbeddedPostgresTestDatabase("environment-runtime-contract"); @@ -64,9 +62,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { }); afterEach(async () => { - for (const server of servers.splice(0)) { - await new Promise((resolve) => server.close(() => resolve())); - } while (fixtureRoots.length > 0) { const root = fixtureRoots.pop(); if (!root) continue; @@ -172,27 +167,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { }; } - async function startHealthServer() { - const server = createServer((req, res) => { - if (req.url === "/api/health") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ status: "ok" })); - return; - } - res.writeHead(404).end(); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => resolve()); - }); - servers.push(server); - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Expected health server to listen on a TCP port."); - } - return `http://127.0.0.1:${address.port}`; - } - async function runContract(testCase: RuntimeContractCase) { const cleanup = await testCase.setup?.(); try { @@ -288,9 +262,6 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { fixtureRoots.push(fixtureRoot); const fixture = await startSshEnvLabFixture({ statePath: path.join(fixtureRoot, "state.json") }); const sshConfig = await buildSshEnvLabFixtureConfig(fixture); - const runtimeApiUrl = await startHealthServer(); - const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]); await runContract({ name: "ssh", @@ -304,16 +275,8 @@ describeEmbeddedPostgres("environment runtime driver contract", () => { username: sshConfig.username, remoteWorkspacePath: sshConfig.remoteWorkspacePath, remoteCwd: sshConfig.remoteWorkspacePath, - paperclipApiUrl: runtimeApiUrl, }); }, - setup: async () => async () => { - if (previousCandidates === undefined) { - delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - } else { - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates; - } - }, }); }); }); diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index dd9595b9eb2..ffda21c61ee 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { createServer } from "node:http"; import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -329,26 +328,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { const statePath = path.join(fixtureRoot, "state.json"); const fixture = await startSshEnvLabFixture({ statePath }); const sshConfig = await buildSshEnvLabFixtureConfig(fixture); - const healthServer = createServer((req, res) => { - if (req.url === "/api/health") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ status: "ok" })); - return; - } - res.writeHead(404).end(); - }); - await new Promise((resolve, reject) => { - healthServer.once("error", reject); - healthServer.listen(0, "127.0.0.1", () => resolve()); - }); - const address = healthServer.address(); - if (!address || typeof address === "string") { - await new Promise((resolve) => healthServer.close(() => resolve())); - throw new Error("Expected the test health server to listen on a TCP port."); - } - const runtimeApiUrl = `http://127.0.0.1:${address.port}`; - const previousCandidates = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify([runtimeApiUrl]); const { companyId, environment, runId } = await seedEnvironment({ driver: "ssh", name: "Fixture SSH", @@ -372,7 +351,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { username: sshConfig.username, remoteWorkspacePath: sshConfig.remoteWorkspacePath, remoteCwd: sshConfig.remoteWorkspacePath, - paperclipApiUrl: runtimeApiUrl, }); const released = await runtime.releaseRunLeases(runId); @@ -381,12 +359,6 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { expect(released[0]?.environment.driver).toBe("ssh"); expect(released[0]?.lease.status).toBe("released"); } finally { - if (previousCandidates === undefined) { - delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - } else { - process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = previousCandidates; - } - await new Promise((resolve) => healthServer.close(() => resolve())); } }); diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index c2f2e4fb8fd..2a506d414ea 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -58,10 +58,6 @@ export async function resolveEnvironmentExecutionTarget(input: { ? input.leaseMetadata.remoteCwd.trim() : DEFAULT_SANDBOX_REMOTE_CWD; const timeoutMs = "timeoutMs" in parsed.config ? parsed.config.timeoutMs : null; - const paperclipApiUrl = - typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0 - ? input.leaseMetadata.paperclipApiUrl.trim() - : null; const shellCommand = input.leaseMetadata?.shellCommand === "bash" || input.leaseMetadata?.shellCommand === "sh" ? input.leaseMetadata.shellCommand @@ -75,8 +71,6 @@ export async function resolveEnvironmentExecutionTarget(input: { remoteCwd, environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, - paperclipApiUrl, - paperclipTransport: paperclipApiUrl ? "direct" : "bridge", timeoutMs, runner: input.environmentRuntime && input.lease ? { @@ -143,10 +137,6 @@ export async function resolveEnvironmentExecutionTarget(input: { environmentId: input.environment.id ?? null, leaseId: input.leaseId ?? null, remoteCwd, - paperclipApiUrl: - typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0 - ? input.leaseMetadata.paperclipApiUrl.trim() - : null, spec: { host: parsed.config.host, port: parsed.config.port, @@ -156,10 +146,6 @@ export async function resolveEnvironmentExecutionTarget(input: { knownHosts: parsed.config.knownHosts, strictHostKeyChecking: parsed.config.strictHostKeyChecking, remoteCwd, - paperclipApiUrl: - typeof input.leaseMetadata?.paperclipApiUrl === "string" && input.leaseMetadata.paperclipApiUrl.trim().length > 0 - ? input.leaseMetadata.paperclipApiUrl.trim() - : null, }, }; } diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index d83244ab16e..2e587b4ac40 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -14,7 +14,7 @@ import type { PluginEnvironmentLease, PluginEnvironmentRealizeWorkspaceResult, } from "@paperclipai/plugin-sdk"; -import { ensureSshWorkspaceReady, findReachablePaperclipApiUrlOverSsh } from "@paperclipai/adapter-utils/ssh"; +import { ensureSshWorkspaceReady } from "@paperclipai/adapter-utils/ssh"; import { environmentService } from "./environments.js"; import { parseEnvironmentDriverConfig, @@ -227,27 +227,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { } const { remoteCwd } = await ensureSshWorkspaceReady(parsed.config); - const candidateUrls = (() => { - const raw = process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; - if (!raw) return []; - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter((value): value is string => typeof value === "string" && value.trim().length > 0) - : []; - } catch { - return []; - } - })(); - const paperclipApiUrl = await findReachablePaperclipApiUrlOverSsh({ - config: parsed.config, - candidates: candidateUrls, - }); - if (!paperclipApiUrl) { - throw new Error( - `SSH environment ${parsed.config.username}@${parsed.config.host} could not reach any Paperclip API candidates.`, - ); - } return await environmentsSvc.acquireLease({ companyId: input.companyId, environmentId: input.environment.id, @@ -265,7 +244,6 @@ function createSshEnvironmentDriver(db: Db): EnvironmentRuntimeDriver { username: parsed.config.username, remoteWorkspacePath: parsed.config.remoteWorkspacePath, remoteCwd, - paperclipApiUrl, }, }); }, From bb7d0408942ba074aea85a14b82d2fafebe68bd2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 13:01:34 -0700 Subject: [PATCH 04/27] Switch OpenCode to explicit static/local-aware model selection (#5117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 4 of 7).** Depends on: - PR #5114 - PR #5115 - PR #5116 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - When creating an OpenCode-local agent, Paperclip currently validates > `adapterConfig.model` against the *Paperclip host's* `opencode models` output > - SSH testing surfaced that this blocks creating an OpenCode agent for an SSH > environment: the model that exists on the SSH target isn't visible to the > host, so creation fails with "OpenCode requires `adapterConfig.model` in > provider/model format" even when the operator picked a real remote model > - The initial direction was environment-aware model discovery; the final > decision was to keep OpenCode on the same explicit-model pattern as other > adapters (default + curated list + manual override) and stop blocking > creation on host-side discovery > - This PR does both: the adapter-models endpoint now accepts `environmentId` and > probes against the target environment, and the create-time hard gate is > replaced by `requireOpenCodeModelId` which validates `provider/model` *format* > without requiring host-local discovery. Test/run-time still surfaces real > auth/availability problems > - The benefit is that operators can create OpenCode agents for remote > environments without out-of-band setup, and the model picker in the UI > reflects the actually-targeted environment ## What Changed - Added `requireOpenCodeModelId(input)` in `opencode-local/src/server/models.ts`, exported it from the adapter index - `ensureOpenCodeModelConfiguredAndAvailable` now delegates the format check to `requireOpenCodeModelId` - `agentsApi.adapterModels(companyId, adapterType, { environmentId })` now accepts an environment ID and passes it as a query parameter - `queryKeys.agents.adapterModels` now keys on `(companyId, adapterType, environmentId)` - `server/src/routes/agents.ts` reads and validates the new query parameter, forwarding it to the adapter's model probe - `AgentConfigForm.tsx` and `OnboardingWizard.tsx` build the model query key from the currently selected default environment ID and disable autodetect for `opencode_local` (model selection is explicit) - `NewAgent.tsx` simplified — no longer special-cases OpenCode autodetect - `company-portability.ts` no longer needs OpenCode-specific autodetect handling - Tests added/updated: `adapter-model-refresh-routes.test.ts`, `adapter-models.test.ts`, `agent-permissions-routes.test.ts`, `opencode-local/src/server/models.test.ts` ## Verification - `pnpm --filter @paperclipai/server test -- adapter-models adapter-model-refresh agent-permissions` - `pnpm --filter @paperclipai/adapter-opencode-local test` - `pnpm --filter @paperclipai/ui test -- AgentConfigForm OnboardingWizard NewAgent` - Manual QA in browser: 1. Boot Paperclip on Tailscale-bound port (so it's reachable from another machine), create an OpenCode-local agent, switch the default environment between two installed sandboxes, and confirm the model list refreshes per-environment 2. Submit with a malformed `provider/model` string and verify the new `requireOpenCodeModelId` error surfaces - Before/after screenshots attached for `AgentConfigForm` model picker ## Risks - Behavioural shift: switching default environment now triggers a model refetch. Should be cheap but introduces a new UI loading state for OpenCode users. - Removing dynamic autodetect for OpenCode: if any user configured an agent without specifying `model` and relied on autodetect populating it, that agent will now fail at submit time. Mitigation: validation error is explicit and actionable. - New query string parameter on `/api/companies/:id/adapter-models` — older clients that omit it still work (parameter is optional and defaults to null). ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- packages/adapters/opencode-local/src/index.ts | 7 ++ .../opencode-local/src/server/index.ts | 1 + .../opencode-local/src/server/models.test.ts | 14 ++++ .../opencode-local/src/server/models.ts | 14 +++- .../adapter-model-refresh-routes.test.ts | 66 +++++++++++++++ server/src/__tests__/adapter-models.test.ts | 7 ++ .../agent-permissions-routes.test.ts | 80 +++++++++++++++++++ server/src/routes/agents.ts | 30 ++++--- server/src/services/company-portability.ts | 14 +--- ui/src/api/agents.ts | 17 +++- ui/src/components/AgentConfigForm.tsx | 60 ++++++++------ ui/src/components/OnboardingWizard.tsx | 65 +++++---------- ui/src/lib/queryKeys.ts | 4 +- ui/src/pages/NewAgent.tsx | 41 +--------- 14 files changed, 281 insertions(+), 139 deletions(-) diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index ff326f99b99..885228af77d 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -5,6 +5,13 @@ export const label = "OpenCode (local)"; export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex"; +export function isValidOpenCodeModelId(value: unknown): value is string { + if (typeof value !== "string") return false; + const trimmed = value.trim(); + const slashIndex = trimmed.indexOf("/"); + return Boolean(trimmed) && slashIndex > 0 && slashIndex !== trimmed.length - 1; +} + export const models: Array<{ id: string; label: string }> = [ { id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL }, { id: "openai/gpt-5.4", label: "openai/gpt-5.4" }, diff --git a/packages/adapters/opencode-local/src/server/index.ts b/packages/adapters/opencode-local/src/server/index.ts index 3c92f753cf1..a57970d0a04 100644 --- a/packages/adapters/opencode-local/src/server/index.ts +++ b/packages/adapters/opencode-local/src/server/index.ts @@ -67,6 +67,7 @@ export { listOpenCodeModels, discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable, + requireOpenCodeModelId, resetOpenCodeModelsCacheForTests, } from "./models.js"; export { parseOpenCodeJsonl, isOpenCodeUnknownSessionError } from "./parse.js"; diff --git a/packages/adapters/opencode-local/src/server/models.test.ts b/packages/adapters/opencode-local/src/server/models.test.ts index cd49e4a2742..0f0bea0c80c 100644 --- a/packages/adapters/opencode-local/src/server/models.test.ts +++ b/packages/adapters/opencode-local/src/server/models.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { ensureOpenCodeModelConfiguredAndAvailable, listOpenCodeModels, + requireOpenCodeModelId, resetOpenCodeModelsCacheForTests, } from "./models.js"; @@ -22,6 +23,19 @@ describe("openCode models", () => { ).rejects.toThrow("OpenCode requires `adapterConfig.model`"); }); + it("accepts a provider/model id without running discovery", () => { + expect(requireOpenCodeModelId("openai/gpt-5.2-codex")).toBe("openai/gpt-5.2-codex"); + }); + + it("rejects malformed provider/model ids before discovery", () => { + expect(() => requireOpenCodeModelId("gpt-5.2-codex")).toThrow( + "OpenCode requires `adapterConfig.model`", + ); + expect(() => requireOpenCodeModelId("openai/")).toThrow( + "OpenCode requires `adapterConfig.model`", + ); + }); + it("rejects when discovery cannot run for configured model", async () => { process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; await expect( diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index 95cb1fc9431..31e59ac2ce3 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -6,6 +6,7 @@ import { ensurePathInEnv, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import { isValidOpenCodeModelId } from "../index.js"; const MODELS_CACHE_TTL_MS = 60_000; const MODELS_DISCOVERY_TIMEOUT_MS = 20_000; @@ -23,6 +24,14 @@ const discoveryCache = new Map(); const deduped: AdapterModel[] = []; @@ -172,10 +181,7 @@ export async function ensureOpenCodeModelConfiguredAndAvailable(input: { cwd?: unknown; env?: unknown; }): Promise { - const model = asString(input.model, "").trim(); - if (!model) { - throw new Error("OpenCode requires `adapterConfig.model` in provider/model format."); - } + const model = requireOpenCodeModelId(input.model); const models = await discoverOpenCodeModelsCached({ command: input.command, diff --git a/server/src/__tests__/adapter-model-refresh-routes.test.ts b/server/src/__tests__/adapter-model-refresh-routes.test.ts index 5be7a5a0fe9..68553e9d31d 100644 --- a/server/src/__tests__/adapter-model-refresh-routes.test.ts +++ b/server/src/__tests__/adapter-model-refresh-routes.test.ts @@ -1,8 +1,16 @@ import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { models as openCodeFallbackModels } from "@paperclipai/adapter-opencode-local"; import type { ServerAdapterModule } from "../adapters/index.js"; +vi.mock("acpx/runtime", () => ({ + createAcpRuntime: vi.fn(), + createAgentRegistry: vi.fn(), + createRuntimeStore: vi.fn(), + isAcpRuntimeError: vi.fn(() => false), +})); + const mockAccessService = vi.hoisted(() => ({ canUser: vi.fn(), hasPermission: vi.fn(), @@ -19,6 +27,10 @@ const mockSecretService = vi.hoisted(() => ({ normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record) => config), resolveAdapterConfigForRuntime: vi.fn(async (_companyId: string, config: Record) => ({ config })), })); +const mockEnvironmentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); +const mockListOpenCodeModels = vi.hoisted(() => vi.fn()); const mockAgentInstructionsService = vi.hoisted(() => ({ materializeManagedBundle: vi.fn(), @@ -55,6 +67,14 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); function registerModuleMocks() { + vi.doMock("@paperclipai/adapter-opencode-local/server", async () => { + const actual = await vi.importActual("@paperclipai/adapter-opencode-local/server"); + return { + ...actual, + listOpenCodeModels: mockListOpenCodeModels, + }; + }); + vi.doMock("../services/index.js", () => ({ agentService: () => ({}), agentInstructionsService: () => mockAgentInstructionsService, @@ -74,6 +94,10 @@ function registerModuleMocks() { vi.doMock("../services/instance-settings.js", () => ({ instanceSettingsService: () => mockInstanceSettingsService, })); + + vi.doMock("../services/environments.js", () => ({ + environmentService: () => mockEnvironmentService, + })); } const refreshableAdapterType = "refreshable_adapter_route_test"; @@ -147,6 +171,10 @@ describe("adapter model refresh route", () => { mockAccessService.ensureMembership.mockResolvedValue(undefined); mockAccessService.setPrincipalPermission.mockResolvedValue(undefined); mockLogActivity.mockResolvedValue(undefined); + mockEnvironmentService.getById.mockReset(); + mockEnvironmentService.getById.mockResolvedValue(null); + mockListOpenCodeModels.mockReset(); + mockListOpenCodeModels.mockResolvedValue([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]); await unregisterTestAdapter(refreshableAdapterType); }); @@ -182,4 +210,42 @@ describe("adapter model refresh route", () => { expect(refreshModels).toHaveBeenCalledTimes(1); expect(listModels).not.toHaveBeenCalled(); }); + + it("skips OpenCode model discovery for non-local environments", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "env-1", + companyId: "company-1", + name: "Remote SSH", + driver: "ssh", + config: {}, + }); + + const app = await createApp(); + const res = await requestApp(app, (baseUrl) => + request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"), + ); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual(openCodeFallbackModels); + expect(mockListOpenCodeModels).not.toHaveBeenCalled(); + }); + + it("keeps OpenCode model discovery enabled for local environments", async () => { + mockEnvironmentService.getById.mockResolvedValue({ + id: "env-1", + companyId: "company-1", + name: "Local", + driver: "local", + config: {}, + }); + + const app = await createApp(); + const res = await requestApp(app, (baseUrl) => + request(baseUrl).get("/api/companies/company-1/adapters/opencode_local/models?environmentId=env-1"), + ); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toEqual([{ id: "dynamic-opencode-model", label: "dynamic-opencode-model" }]); + expect(mockListOpenCodeModels).toHaveBeenCalledTimes(1); + }); }); diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index 2be936d98ab..9e0ec35917c 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -7,6 +7,13 @@ import { listAdapterModels, refreshAdapterModels } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js"; +vi.mock("acpx/runtime", () => ({ + createAcpRuntime: vi.fn(), + createAgentRegistry: vi.fn(), + createRuntimeStore: vi.fn(), + isAcpRuntimeError: vi.fn(() => false), +})); + describe("adapter model listing", () => { beforeEach(() => { delete process.env.OPENAI_API_KEY; diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 624f37727e4..218d653f8bc 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,6 +1,14 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; + +vi.mock("acpx/runtime", () => ({ + createAcpRuntime: vi.fn(), + createAgentRegistry: vi.fn(), + createRuntimeStore: vi.fn(), + isAcpRuntimeError: vi.fn(() => false), +})); const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -908,6 +916,78 @@ describe.sequential("agent permission routes", () => { ); }); + it("seeds opencode agent creation with the static default model without live discovery", async () => { + mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue( + new Error("`opencode models` should not be called during creation"), + ); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "OpenCode Builder", + role: "engineer", + adapterType: "opencode_local", + adapterConfig: {}, + })); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled(); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + adapterType: "opencode_local", + adapterConfig: expect.objectContaining({ + model: DEFAULT_OPENCODE_LOCAL_MODEL, + }), + }), + ); + }); + + it("accepts manual opencode provider/model values without host-side discovery", async () => { + mockEnsureOpenCodeModelConfiguredAndAvailable.mockRejectedValue( + new Error("`opencode models` should not be called during creation"), + ); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "local_implicit", + isInstanceAdmin: true, + companyIds: [companyId], + }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/agents`) + .send({ + name: "OpenCode Builder", + role: "engineer", + adapterType: "opencode_local", + adapterConfig: { + model: "anthropic/claude-sonnet-4-5", + }, + })); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(mockEnsureOpenCodeModelConfiguredAndAvailable).not.toHaveBeenCalled(); + expect(mockAgentService.create).toHaveBeenCalledWith( + companyId, + expect.objectContaining({ + adapterType: "opencode_local", + adapterConfig: expect.objectContaining({ + model: "anthropic/claude-sonnet-4-5", + }), + }), + ); + }); + it("normalizes hire requests to disable timer heartbeats by default", async () => { const app = await createApp({ type: "board", diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c9b755d294b..3e92cacf887 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -84,7 +84,8 @@ import { } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; -import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; +import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server"; import { loadDefaultAgentInstructionsBundle, resolveDefaultAgentInstructionsBundleRole, @@ -767,7 +768,6 @@ export function agentRoutes( { strictMode: strictSecretsMode }, ); await assertAdapterConfigConstraints( - input.companyId, input.adapterType, input.constraintAdapterConfig ? { ...input.constraintAdapterConfig, ...normalizedAdapterConfig } @@ -864,7 +864,10 @@ export function agentRoutes( next.model = DEFAULT_GEMINI_LOCAL_MODEL; return ensureGatewayDeviceKey(adapterType, next); } - // OpenCode requires explicit model selection — no default + if (adapterType === "opencode_local" && !asNonEmptyString(next.model)) { + next.model = DEFAULT_OPENCODE_LOCAL_MODEL; + return ensureGatewayDeviceKey(adapterType, next); + } if (adapterType === "cursor" && !asNonEmptyString(next.model)) { next.model = DEFAULT_CURSOR_LOCAL_MODEL; } @@ -872,20 +875,12 @@ export function agentRoutes( } async function assertAdapterConfigConstraints( - companyId: string, adapterType: string | null | undefined, adapterConfig: Record, ) { if (adapterType !== "opencode_local") return; - const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig); - const runtimeEnv = asRecord(runtimeConfig.env) ?? {}; try { - await ensureOpenCodeModelConfiguredAndAvailable({ - model: runtimeConfig.model, - command: runtimeConfig.command, - cwd: runtimeConfig.cwd, - env: runtimeEnv, - }); + requireOpenCodeModelId(adapterConfig.model); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); @@ -1194,6 +1189,17 @@ export function agentRoutes( const refresh = typeof req.query.refresh === "string" ? ["1", "true", "yes"].includes(req.query.refresh.toLowerCase()) : false; + const environmentId = asNonEmptyString(req.query.environmentId); + const environment = environmentId ? await environmentsSvc.getById(environmentId) : null; + if (environmentId && (!environment || environment.companyId !== companyId)) { + res.status(404).json({ error: "Environment not found" }); + return; + } + if (type === "opencode_local" && environment && environment.driver !== "local") { + const adapter = requireServerAdapter(type); + res.json(adapter.models ?? []); + return; + } const models = refresh ? await refreshAdapterModels(type) : await listAdapterModels(type); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 88b66f6d8f5..9cd1946d8a4 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -48,7 +48,7 @@ import { readPaperclipSkillSyncPreference, writePaperclipSkillSyncPreference, } from "@paperclipai/adapter-utils/server-utils"; -import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server"; +import { requireOpenCodeModelId } from "@paperclipai/adapter-opencode-local/server"; import { findServerAdapter } from "../adapters/index.js"; import { forbidden, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; @@ -2781,20 +2781,12 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { } async function assertImportAdapterConfigConstraints( - companyId: string, adapterType: string, adapterConfig: Record, ) { if (adapterType !== "opencode_local") return; - const { config: runtimeConfig } = await secrets.resolveAdapterConfigForRuntime(companyId, adapterConfig); - const runtimeEnv = isPlainRecord(runtimeConfig.env) ? runtimeConfig.env : {}; try { - await ensureOpenCodeModelConfiguredAndAvailable({ - model: runtimeConfig.model, - command: runtimeConfig.command, - cwd: runtimeConfig.cwd, - env: runtimeEnv, - }); + requireOpenCodeModelId(adapterConfig.model); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw unprocessable(`Invalid opencode_local adapterConfig: ${reason}`); @@ -2824,7 +2816,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { nextAdapterConfig, { strictMode: strictSecretsMode }, ); - await assertImportAdapterConfigConstraints(companyId, effectiveAdapterType, normalizedAdapterConfig); + await assertImportAdapterConfigConstraints(effectiveAdapterType, normalizedAdapterConfig); return { adapterType: effectiveAdapterType, adapterConfig: normalizedAdapterConfig, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index d3e79fc06ed..f25aa5b30e4 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -171,10 +171,19 @@ export const agentsApi = { api.get(agentPath(id, companyId, "/task-sessions")), resetSession: (id: string, taskKey?: string | null, companyId?: string) => api.post(agentPath(id, companyId, "/runtime-state/reset-session"), { taskKey: taskKey ?? null }), - adapterModels: (companyId: string, type: string, options?: { refresh?: boolean }) => - api.get( - `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${options?.refresh ? "?refresh=1" : ""}`, - ), + adapterModels: ( + companyId: string, + type: string, + options?: { refresh?: boolean; environmentId?: string | null }, + ) => { + const params = new URLSearchParams(); + if (options?.refresh) params.set("refresh", "1"); + if (options?.environmentId) params.set("environmentId", options.environmentId); + const query = params.size > 0 ? `?${params.toString()}` : ""; + return api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models${query}`, + ); + }, detectModel: (companyId: string, type: string) => api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 5e232665e99..9f4804eb0b8 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -20,6 +20,7 @@ import { } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local"; import { Popover, PopoverContent, @@ -322,6 +323,17 @@ export function AgentConfigForm(props: AgentConfigFormProps) { () => new Set(supportedEnvironmentDriversForAdapter(adapterType)), [adapterType], ); + const val = isCreate ? props.values : null; + const set = isCreate + ? (patch: Partial) => props.onChange(patch) + : null; + const currentDefaultEnvironmentId = isCreate + ? val!.defaultEnvironmentId ?? "" + : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); + const currentDefaultEnvironment = useMemo( + () => environments.find((environment) => environment.id === currentDefaultEnvironmentId) ?? null, + [currentDefaultEnvironmentId, environments], + ); const runnableEnvironments = useMemo( () => environments.filter((environment) => { if (!supportedEnvironmentDrivers.has(environment.driver)) return false; @@ -334,14 +346,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { // Fetch adapter models for the effective adapter type const modelQueryKey = selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType) + ? queryKeys.agents.adapterModels(selectedCompanyId, adapterType, currentDefaultEnvironmentId || null) : ["agents", "none", "adapter-models", adapterType]; const { data: fetchedModels, error: fetchedModelsError, } = useQuery({ queryKey: modelQueryKey, - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType), + queryFn: () => agentsApi.adapterModels(selectedCompanyId!, adapterType, { + environmentId: currentDefaultEnvironmentId || null, + }), enabled: Boolean(selectedCompanyId), }); const [refreshModelsError, setRefreshModelsError] = useState(null); @@ -362,7 +376,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } return agentsApi.detectModel(selectedCompanyId, adapterType); }, - enabled: Boolean(selectedCompanyId && isLocal), + enabled: Boolean(selectedCompanyId && isLocal && adapterType !== "opencode_local"), }); const detectedModel = detectedModelData?.model ?? null; const detectedModelCandidates = detectedModelData?.candidates ?? []; @@ -415,12 +429,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { return typeof value === "string" ? value : ""; }, [adapterCheapDefault]); - // Create mode helpers - const val = isCreate ? props.values : null; - const set = isCreate - ? (patch: Partial) => props.onChange(patch) - : null; - function buildAdapterConfigForTest(): Record { if (isCreate) { return uiAdapter.buildAdapterConfig(val!); @@ -446,15 +454,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { if (!selectedCompanyId) { throw new Error("Select a company to test adapter environment"); } - const selectedEnvironmentId = isCreate - ? val!.defaultEnvironmentId ?? null - : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? null); return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig: buildAdapterConfigForTest(), - environmentId: - typeof selectedEnvironmentId === "string" && selectedEnvironmentId.length > 0 - ? selectedEnvironmentId - : null, + environmentId: currentDefaultEnvironmentId || null, }); }, }); @@ -638,9 +640,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) { heartbeat: mergedHeartbeat, }; }, [isCreate, overlay.heartbeat, runtimeConfig, val]); - const currentDefaultEnvironmentId = isCreate - ? val!.defaultEnvironmentId ?? "" - : eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? ""); const effectiveHeartbeat = asObject(effectiveRuntimeConfig.heartbeat); const maxTurnContinuation = asObject(effectiveHeartbeat.maxTurnContinuation); const maxTurnContinuationEnabled = asBoolean(maxTurnContinuation.enabled, true); @@ -834,7 +833,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } else if (t === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (t === "opencode_local") { - nextValues.model = ""; + nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL; } set!(nextValues); } else { @@ -850,9 +849,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? DEFAULT_CODEX_LOCAL_MODEL : t === "gemini_local" ? DEFAULT_GEMINI_LOCAL_MODEL + : t === "opencode_local" + ? DEFAULT_OPENCODE_LOCAL_MODEL : t === "cursor" ? DEFAULT_CURSOR_LOCAL_MODEL - : "", + : "", effort: "", modelReasoningEffort: "", variant: "", @@ -975,10 +976,12 @@ export function AgentConfigForm(props: AgentConfigFormProps) { creatable detectedModel={detectedModel} detectedModelCandidates={[]} - onDetectModel={async () => { - const result = await refetchDetectedModel(); - return result.data?.model ?? null; - }} + onDetectModel={adapterType === "opencode_local" + ? undefined + : async () => { + const result = await refetchDetectedModel(); + return result.data?.model ?? null; + }} onRefreshModels={adapterType === "codex_local" ? handleRefreshModels : undefined} refreshingModels={refreshingModels} detectModelLabel="Detect model" @@ -992,6 +995,13 @@ export function AgentConfigForm(props: AgentConfigFormProps) { : "Failed to load adapter models.")}

)} + {adapterType === "opencode_local" + && currentDefaultEnvironment + && currentDefaultEnvironment.driver !== "local" && ( +

+ Live OpenCode model discovery only runs for Local environments. Using the curated list and manual entry for {currentDefaultEnvironment.name}. +

+ )} {supportsModelProfiles && ( agentsApi.adapterModels(createdCompanyId!, adapterType), + ? queryKeys.agents.adapterModels(createdCompanyId, adapterType, null) + : ["agents", "none", "adapter-models", adapterType, null], + queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType, { environmentId: null }), enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2 }); const getCapabilities = useAdapterCapabilities(); @@ -329,8 +327,10 @@ export function OnboardingWizard() { : adapterType === "gemini_local" ? model || DEFAULT_GEMINI_LOCAL_MODEL : adapterType === "cursor" - ? model || DEFAULT_CURSOR_LOCAL_MODEL - : model, + ? model || DEFAULT_CURSOR_LOCAL_MODEL + : adapterType === "opencode_local" + ? model || DEFAULT_OPENCODE_LOCAL_MODEL + : model, command, args, url, @@ -427,36 +427,12 @@ export function OnboardingWizard() { setError(null); try { if (adapterType === "opencode_local") { - const selectedModelId = model.trim(); - if (!selectedModelId) { + if (!isValidOpenCodeModelId(model)) { setError( "OpenCode requires an explicit model in provider/model format." ); return; } - if (adapterModelsError) { - setError( - adapterModelsError instanceof Error - ? adapterModelsError.message - : "Failed to load OpenCode models." - ); - return; - } - if (adapterModelsLoading || adapterModelsFetching) { - setError( - "OpenCode models are still loading. Please wait and try again." - ); - return; - } - const discoveredModels = adapterModels ?? []; - if (!discoveredModels.some((entry) => entry.id === selectedModelId)) { - setError( - discoveredModels.length === 0 - ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." - : `Configured OpenCode model is unavailable: ${selectedModelId}` - ); - return; - } } if (isLocalAdapter) { @@ -777,12 +753,17 @@ export function OnboardingWizard() { onClick={() => { const nextType = opt.type; setAdapterType(nextType); - if (nextType === "codex_local" && !model) { - setModel(DEFAULT_CODEX_LOCAL_MODEL); + if (nextType === "codex_local") { + if (!model) { + setModel(DEFAULT_CODEX_LOCAL_MODEL); + } + return; } - if (nextType !== "codex_local") { - setModel(""); + if (nextType === "opencode_local") { + setModel(DEFAULT_OPENCODE_LOCAL_MODEL); + return; } + setModel(""); }} > {opt.recommended && ( @@ -839,9 +820,7 @@ export function OnboardingWizard() { return; } if (nextType === "opencode_local") { - if (!model.includes("/")) { - setModel(""); - } + setModel(DEFAULT_OPENCODE_LOCAL_MODEL); return; } setModel(""); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 590af1067a1..0756d7b5d12 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -23,8 +23,8 @@ export const queryKeys = { ["agents", "instructions-bundle", id, "file", relativePath] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, - adapterModels: (companyId: string, adapterType: string) => - ["agents", companyId, "adapter-models", adapterType] as const, + adapterModels: (companyId: string, adapterType: string, environmentId?: string | null) => + ["agents", companyId, "adapter-models", adapterType, environmentId ?? null] as const, adapterModelProfiles: (companyId: string, adapterType: string) => ["agents", companyId, "adapter-model-profiles", adapterType] as const, detectModel: (companyId: string, adapterType: string) => diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index d238ef86031..bf4cb1e9c83 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -34,6 +34,7 @@ import { } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local"; function createValuesForAdapterType( adapterType: CreateConfigValues["adapterType"], @@ -49,7 +50,7 @@ function createValuesForAdapterType( } else if (adapterType === "cursor") { nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL; } else if (adapterType === "opencode_local") { - nextValues.model = ""; + nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL; } return nextValues; } @@ -86,19 +87,6 @@ export function NewAgent() { enabled: !!selectedCompanyId, }); - const { - data: adapterModels, - error: adapterModelsError, - isLoading: adapterModelsLoading, - isFetching: adapterModelsFetching, - } = useQuery({ - queryKey: selectedCompanyId - ? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType) - : ["agents", "none", "adapter-models", configValues.adapterType], - queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType), - enabled: Boolean(selectedCompanyId), - }); - const { data: companySkills } = useQuery({ queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""), queryFn: () => companySkillsApi.list(selectedCompanyId!), @@ -154,32 +142,10 @@ export function NewAgent() { if (!selectedCompanyId || !name.trim()) return; setFormError(null); if (configValues.adapterType === "opencode_local") { - const selectedModel = configValues.model.trim(); - if (!selectedModel) { + if (!isValidOpenCodeModelId(configValues.model)) { setFormError("OpenCode requires an explicit model in provider/model format."); return; } - if (adapterModelsError) { - setFormError( - adapterModelsError instanceof Error - ? adapterModelsError.message - : "Failed to load OpenCode models.", - ); - return; - } - if (adapterModelsLoading || adapterModelsFetching) { - setFormError("OpenCode models are still loading. Please wait and try again."); - return; - } - const discovered = adapterModels ?? []; - if (!discovered.some((entry) => entry.id === selectedModel)) { - setFormError( - discovered.length === 0 - ? "No OpenCode models discovered. Run `opencode models` and authenticate providers." - : `Configured OpenCode model is unavailable: ${selectedModel}`, - ); - return; - } } createAgent.mutate( buildNewAgentHirePayload({ @@ -295,7 +261,6 @@ export function NewAgent() { mode="create" values={configValues} onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))} - adapterModels={adapterModels} onTestActionChange={handleTestAgentActionChange} onTestActionStateChange={handleTestAgentStateChange} onTestFeedbackChange={handleTestAgentFeedbackChange} From 856c6cb192e53a992875821297b5fd8d29c95c2d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 13:17:52 -0700 Subject: [PATCH 05/27] Fix remote workspace environment shaping (#5118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 5 of 7).** Depends on: - PR #5114 - PR #5115 - PR #5116 - PR #5117 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents run with a Paperclip-shaped environment (`PAPERCLIP_WORKSPACE_CWD`, > worktree path, `PAPERCLIP_WORKSPACES_JSON` hints) so the CLI can locate the > correct project tree > - SSH testing reproduced a real failure: a Codex SSH run wrote to > `/tmp/paperclip-env-matrix-...` (the *host* path) instead of the realized > remote workspace at `/home//paperclip-env-matrix-ssh-claude/...` > because the adapter injected `PAPERCLIP_WORKSPACE_CWD=/tmp/...` into the > remote env > - Code review on the initial codex-only fix asked to roll the same approach > into every other SSH-capable adapter (claude, acpx, cursor, opencode, gemini, > pi) via a shared helper rather than duplicating per-adapter > - This PR adds `shapePaperclipWorkspaceEnvForExecution` in adapter-utils that, > when the execution target is remote: replaces local cwd with the realized > execution cwd, nulls out worktree path (which has no remote meaning), and > rewrites/strips `cwd` entries in workspace hints based on what was actually > synced. Every adapter calls it before invoking the remote runner > - The benefit is that remote runs see the realized remote workspace, host-local > paths stop leaking into remote env, and the rule is unit-tested in one place ## What Changed - Added `shapePaperclipWorkspaceEnvForExecution` to `packages/adapter-utils/src/server-utils.ts` with full unit coverage (`server-utils.test.ts`) - Each of acpx-local, claude-local, codex-local, cursor-local, gemini-local, opencode-local, pi-local now calls the new shaper before issuing the remote command and feeds the shaped values into `applyPaperclipWorkspaceEnv` - Per-adapter `execute.remote.test.ts` files extended to cover the new shaping behaviour: localhost paths replaced with remote cwd, foreign-cwd hints stripped, worktree path nulled out for remote targets - `acpx-local/src/server/execute.test.ts` extended with shaping coverage ## Verification - `pnpm test -- server-utils execute.remote` - `pnpm --filter @paperclipai/adapter-acpx-local test` - Manual QA reproducing the original failure: 1. Provision an E2B sandbox environment for the Paperclip QA company 2. Assign an issue to a remote-targeted claude-local agent and confirm the run starts in the correct remote cwd (no `/Users/...` path leakage in the run logs) 3. Repeat for opencode-local and pi-local ## Risks - Behavioural shift: hints whose `cwd` doesn't match the workspace cwd are now stripped on remote targets. If any adapter relied on a leaked local hint cwd, it will see a missing `cwd` instead. Reviewed all current callers — none do. - Adds a small per-run cost (path resolve + string normalisation) on every remote execution. Negligible. - Worktree path is now nulled out on remote (it has no meaning there). Adapters that previously read the value defensively will continue to work. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapter-utils/src/server-utils.test.ts | 65 +++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 73 +++++++++++++++++++ .../acpx-local/src/server/execute.test.ts | 72 ++++++++++++++++-- .../adapters/acpx-local/src/server/execute.ts | 25 +++++-- .../src/server/execute.remote.test.ts | 37 ++++++++++ .../claude-local/src/server/execute.ts | 18 ++++- .../src/server/execute.remote.test.ts | 37 ++++++++++ .../codex-local/src/server/execute.ts | 16 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../cursor-local/src/server/execute.ts | 15 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../gemini-local/src/server/execute.ts | 15 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../opencode-local/src/server/execute.ts | 14 +++- .../src/server/execute.remote.test.ts | 30 ++++++++ .../adapters/pi-local/src/server/execute.ts | 13 +++- 16 files changed, 487 insertions(+), 33 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 16ad53031dd..1b74aefe090 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -12,6 +12,7 @@ import { renderPaperclipWakePrompt, runningProcesses, runChildProcess, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, } from "./server-utils.js"; @@ -551,6 +552,70 @@ describe("applyPaperclipWorkspaceEnv", () => { }); }); +describe("shapePaperclipWorkspaceEnvForExecution", () => { + it("rewrites workspace env paths for remote execution", () => { + const shaped = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: "/tmp/workspace", + workspaceWorktreePath: "/tmp/worktree", + workspaceHints: [ + { + workspaceId: "workspace-1", + cwd: "/tmp/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-2", + cwd: "/tmp/other-workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-3", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + ], + executionTargetIsRemote: true, + executionCwd: "/remote/workspace", + }); + + expect(shaped).toEqual({ + workspaceCwd: "/remote/workspace", + workspaceWorktreePath: null, + workspaceHints: [ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + { + workspaceId: "workspace-3", + repoUrl: "https://github.com/paperclipai/paperclip.git", + }, + ], + }); + }); + + it("leaves local execution workspace paths unchanged", () => { + const workspaceHints = [{ workspaceId: "workspace-1", cwd: "/tmp/workspace" }]; + const shaped = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: "/tmp/workspace", + workspaceWorktreePath: "/tmp/worktree", + workspaceHints, + executionTargetIsRemote: false, + executionCwd: "/remote/workspace", + }); + + expect(shaped).toEqual({ + workspaceCwd: "/tmp/workspace", + workspaceWorktreePath: "/tmp/worktree", + workspaceHints, + }); + }); +}); + describe("appendWithByteCap", () => { it("keeps valid UTF-8 when trimming through multibyte text", () => { const output = appendWithByteCap("prefix ", "hello — world", 7); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index bb4eb40de60..cc6e4566c4d 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -885,6 +885,79 @@ export function applyPaperclipWorkspaceEnv( return env; } +export function shapePaperclipWorkspaceEnvForExecution(input: { + workspaceCwd?: string | null; + workspaceWorktreePath?: string | null; + workspaceHints?: Array>; + executionTargetIsRemote?: boolean; + executionCwd?: string | null; +}): { + workspaceCwd: string | null; + workspaceWorktreePath: string | null; + workspaceHints: Array>; +} { + const workspaceCwd = + typeof input.workspaceCwd === "string" && input.workspaceCwd.trim().length > 0 + ? input.workspaceCwd.trim() + : null; + const workspaceWorktreePath = + typeof input.workspaceWorktreePath === "string" && input.workspaceWorktreePath.trim().length > 0 + ? input.workspaceWorktreePath.trim() + : null; + const workspaceHints = Array.isArray(input.workspaceHints) ? input.workspaceHints : []; + + if (!input.executionTargetIsRemote) { + return { + workspaceCwd, + workspaceWorktreePath, + workspaceHints, + }; + } + + const executionCwd = + typeof input.executionCwd === "string" && input.executionCwd.trim().length > 0 + ? input.executionCwd.trim() + : null; + // On a remote target we must never fall back to the local workspaceCwd — + // doing so leaks host paths into the remote env (the exact failure mode + // this helper exists to prevent). Callers are expected to resolve + // executionCwd via adapterExecutionTargetRemoteCwd before calling this + // helper, which always returns a non-empty string. Surface a warning so + // future callers don't silently regress to the leak. + if (executionCwd === null) { + // eslint-disable-next-line no-console + console.warn( + "[paperclip] shapePaperclipWorkspaceEnvForExecution called with executionCwd=null on a remote target; " + + "stripping workspaceCwd to avoid leaking local paths into the remote environment.", + ); + } + const realizedWorkspaceCwd = executionCwd; + const localWorkspaceCwd = workspaceCwd ? path.resolve(workspaceCwd) : null; + const shapedWorkspaceHints = workspaceHints.map((hint) => { + const nextHint = { ...hint }; + const hintCwd = typeof nextHint.cwd === "string" ? nextHint.cwd.trim() : ""; + if (!hintCwd) return nextHint; + + if (localWorkspaceCwd && path.resolve(hintCwd) === localWorkspaceCwd) { + if (realizedWorkspaceCwd) { + nextHint.cwd = realizedWorkspaceCwd; + } else { + delete nextHint.cwd; + } + return nextHint; + } + + delete nextHint.cwd; + return nextHint; + }); + + return { + workspaceCwd: realizedWorkspaceCwd, + workspaceWorktreePath: null, + workspaceHints: shapedWorkspaceHints, + }; +} + export function sanitizeInheritedPaperclipEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...baseEnv }; for (const key of Object.keys(env)) { diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts index 5ebc239e68d..b009b1bcb13 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -56,7 +56,13 @@ function buildRuntime() { }; } -async function runExecutor(config: Record) { +async function runExecutor( + config: Record, + options: { + context?: Record; + executionTransport?: Record; + } = {}, +) { const runtimeOptions: Record[] = []; const meta: Record[] = []; const logs: Array<{ stream: string; text: string }> = []; @@ -73,12 +79,13 @@ async function runExecutor(config: Record) { id: "agent-1", companyId: "company-1", }, - runtime: {}, - config, - context: {}, - onLog: async (stream: "stdout" | "stderr", text: string) => { - logs.push({ stream, text }); - }, + runtime: {}, + config, + context: options.context ?? {}, + executionTransport: options.executionTransport, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, onMeta: async (payload: unknown) => { meta.push(payload as Record); }, @@ -257,6 +264,57 @@ describe("acpx_local runtime skill isolation", () => { expect(env).not.toContain("old-key"); }); + it("shapes ACPX wrapper workspace env for remote execution identities", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const workspaceDir = path.join(root, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + + await runExecutor( + { + agentCommand: "node ./fake-acp.js", + stateDir, + }, + { + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + strategy: "git_worktree", + workspaceId: "workspace-1", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + branchName: "feature/remote-acpx", + worktreePath: workspaceDir, + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + }, + ); + + const wrappers = await fs.readdir(path.join(stateDir, "wrappers")); + const envPath = path.join( + stateDir, + "wrappers", + wrappers.find((name) => name.endsWith(".env"))!, + ); + const env = await fs.readFile(envPath, "utf8"); + + expect(env).toContain("PAPERCLIP_WORKSPACE_CWD='/remote/workspace'"); + expect(env).not.toContain("PAPERCLIP_WORKSPACE_WORKTREE_PATH="); + }); + it("cleans aged credential wrapper scripts across ACPX agent changes", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 6c2840e02df..dc5d4d0c8bd 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -21,6 +21,7 @@ import { renderPaperclipWakePrompt, renderTemplate, resolvePaperclipDesiredSkillNames, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, type PaperclipSkillEntry, } from "@paperclipai/adapter-utils/server-utils"; @@ -622,6 +623,21 @@ async function buildRuntime(input: { const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const executionTarget = readAdapterExecutionTarget({ + executionTarget: input.ctx.executionTarget, + legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution, + }); + const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget); + const effectiveExecutionCwd = + remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string" + ? remoteExecutionIdentity.remoteCwd + : cwd; + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: effectiveWorkspaceCwd, + workspaceWorktreePath, + executionTargetIsRemote: remoteExecutionIdentity !== null, + executionCwd: effectiveExecutionCwd, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const acpxAgent = normalizeAgent(config); @@ -659,14 +675,14 @@ async function buildRuntime(input: { if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; applyPaperclipWorkspaceEnv(env, { - workspaceCwd: effectiveWorkspaceCwd, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceStrategy, workspaceId, workspaceRepoUrl, workspaceRepoRef, workspaceBranch, - workspaceWorktreePath, + workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath, agentHome, }); for (const [key, value] of Object.entries(envConfig)) { @@ -718,11 +734,6 @@ async function buildRuntime(input: { const wrapperPath = wrapper?.wrapperPath ?? null; const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined; const agentRegistry = createAgentRegistry({ overrides }); - const executionTarget = readAdapterExecutionTarget({ - executionTarget: input.ctx.executionTarget, - legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution, - }); - const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget); const fingerprint = shortHash({ acpxAgent, agentCommand: agentCommand ?? acpxAgent, diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index e435ffc3577..dd5ab54cfa2 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -92,8 +92,10 @@ describe("claude remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); const instructionsPath = path.join(rootDir, "instructions.md"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); await writeFile(instructionsPath, "Use the remote workspace.\n", "utf8"); await execute({ @@ -119,7 +121,27 @@ describe("claude remote execution", () => { paperclipWorkspace: { cwd: workspaceDir, source: "project_primary", + strategy: "git_worktree", + workspaceId: "workspace-1", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + branchName: "feature/remote-claude", + worktreePath: workspaceDir, }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -154,6 +176,21 @@ describe("claude remote execution", () => { expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills/agent-instructions.md"); expect(call?.[2]).toContain("--add-dir"); expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/claude/skills"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 804a9c296e7..7c77b66b6c0 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -35,6 +35,7 @@ import { ensurePathInEnv, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, } from "@paperclipai/adapter-utils/server-utils"; @@ -144,6 +145,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: effectiveWorkspaceCwd, + workspaceWorktreePath, + workspaceHints, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const envConfig = parseObject(config.env); @@ -199,18 +209,18 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); } if (runtimeServiceIntents.length > 0) { env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); diff --git a/packages/adapters/codex-local/src/server/execute.remote.test.ts b/packages/adapters/codex-local/src/server/execute.remote.test.ts index acbf0f78525..74e11ab4a60 100644 --- a/packages/adapters/codex-local/src/server/execute.remote.test.ts +++ b/packages/adapters/codex-local/src/server/execute.remote.test.ts @@ -93,6 +93,8 @@ describe("codex remote execution", () => { await mkdir(codexHomeDir, { recursive: true }); await writeFile(path.join(rootDir, "instructions.md"), "Use the remote workspace.\n", "utf8"); await writeFile(path.join(codexHomeDir, "auth.json"), "{}", "utf8"); + const alternateWorkspaceDir = path.join(rootDir, "alternate-workspace"); + await mkdir(alternateWorkspaceDir, { recursive: true }); await execute({ runId: "run-1", @@ -119,7 +121,27 @@ describe("codex remote execution", () => { paperclipWorkspace: { cwd: workspaceDir, source: "project_primary", + strategy: "git_worktree", + workspaceId: "workspace-1", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + branchName: "feature/remote-codex", + worktreePath: workspaceDir, }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -153,6 +175,21 @@ describe("codex remote execution", () => { | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; expect(call?.[3].env.CODEX_HOME).toBe("/remote/workspace/.paperclip-runtime/codex/home"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_WORKTREE_PATH).toBeUndefined(); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index e2617e36d5f..dd2489ed5ed 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -30,6 +30,7 @@ import { resolvePaperclipDesiredSkillNames, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, @@ -347,6 +348,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise { await onLog( @@ -425,18 +433,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); } if (runtimeServiceIntents.length > 0) { env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents); diff --git a/packages/adapters/cursor-local/src/server/execute.remote.test.ts b/packages/adapters/cursor-local/src/server/execute.remote.test.ts index fcf602e3d38..c6450aead39 100644 --- a/packages/adapters/cursor-local/src/server/execute.remote.test.ts +++ b/packages/adapters/cursor-local/src/server/execute.remote.test.ts @@ -99,7 +99,9 @@ describe("cursor remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-cursor-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -124,6 +126,20 @@ describe("cursor remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -167,6 +183,20 @@ describe("cursor remote execution", () => { | undefined; expect(call?.[2]).toContain("--workspace"); expect(call?.[2]).toContain("/remote/workspace"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 123b75bea0f..2f521128f95 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -36,6 +36,7 @@ import { removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, joinPromptSections, @@ -221,6 +222,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: effectiveWorkspaceCwd, + workspaceHints, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries); @@ -281,15 +289,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { - env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; @@ -334,7 +342,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise Promise) | null = null; let localSkillsDir: string | null = null; let remoteRuntimeRootDir: string | null = null; diff --git a/packages/adapters/gemini-local/src/server/execute.remote.test.ts b/packages/adapters/gemini-local/src/server/execute.remote.test.ts index 563d56dccd2..f76ab555e04 100644 --- a/packages/adapters/gemini-local/src/server/execute.remote.test.ts +++ b/packages/adapters/gemini-local/src/server/execute.remote.test.ts @@ -105,7 +105,9 @@ describe("gemini remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -130,6 +132,20 @@ describe("gemini remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -171,6 +187,20 @@ describe("gemini remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 4c7e82423eb..c68dd8cd7f3 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -39,6 +39,7 @@ import { parseObject, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -199,6 +200,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: effectiveWorkspaceCwd, + workspaceHints, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries); @@ -243,14 +251,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; applyPaperclipWorkspaceEnv(env, { - workspaceCwd: effectiveWorkspaceCwd, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, agentHome, }); - if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); + } for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } @@ -279,7 +289,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) return fromExtraArgs; return asStringArray(config.args); })(); - const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); let restoreRemoteWorkspace: (() => Promise) | null = null; let remoteSkillsDir: string | null = null; let localSkillsDir: string | null = null; diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index 21687730598..a42879266da 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -103,7 +103,9 @@ describe("opencode remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -129,6 +131,20 @@ describe("opencode remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -173,6 +189,20 @@ describe("opencode remote execution", () => { const call = runChildProcess.mock.calls[0] as unknown as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].env.XDG_CONFIG_HOME).toBe("/remote/workspace/.paperclip-runtime/opencode/xdgConfig"); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index ca273ac8aef..6630ce986ef 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -34,6 +34,7 @@ import { ensurePathInEnv, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -154,6 +155,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); + const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({ + workspaceCwd: effectiveWorkspaceCwd, + workspaceHints, + executionTargetIsRemote, + executionCwd: effectiveExecutionCwd, + }); await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries); @@ -202,14 +210,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; applyPaperclipWorkspaceEnv(env, { - workspaceCwd: effectiveWorkspaceCwd, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, agentHome, }); - if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); + } for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts index c20df55bf96..9a36bad71d3 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -108,7 +108,9 @@ describe("pi remote execution", () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); + const alternateWorkspaceDir = path.join(rootDir, "workspace-other"); await mkdir(workspaceDir, { recursive: true }); + await mkdir(alternateWorkspaceDir, { recursive: true }); const result = await execute({ runId: "run-1", @@ -134,6 +136,20 @@ describe("pi remote execution", () => { cwd: workspaceDir, source: "project_primary", }, + paperclipWorkspaces: [ + { + workspaceId: "workspace-1", + cwd: workspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + cwd: alternateWorkspaceDir, + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ], }, executionTransport: { remoteExecution: { @@ -178,6 +194,20 @@ describe("pi remote execution", () => { expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("--skill"); expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/skills"); + expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); + expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ + { + workspaceId: "workspace-1", + cwd: "/remote/workspace", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "main", + }, + { + workspaceId: "workspace-2", + repoUrl: "https://github.com/paperclipai/paperclip.git", + repoRef: "feature/other", + }, + ]); expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310"); expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1"); expect(call?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 827eccf75d4..1d1172b7014 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -36,6 +36,7 @@ import { removeMaintainerOnlySkillSymlinks, renderTemplate, renderPaperclipWakePrompt, + shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, @@ -179,6 +180,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; applyPaperclipWorkspaceEnv(env, { - workspaceCwd, + workspaceCwd: shapedWorkspaceEnv.workspaceCwd, workspaceSource, workspaceId, workspaceRepoUrl, workspaceRepoRef, agentHome, }); - if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + if (shapedWorkspaceEnv.workspaceHints.length > 0) { + env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(shapedWorkspaceEnv.workspaceHints); + } for (const [key, value] of Object.entries(envConfig)) { if (typeof value === "string") env[key] = value; } From d22e790bd473d189a88ab9a5e0007d78cc49daaf Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 13:34:09 -0700 Subject: [PATCH 06/27] Validate remote model probes on execution target (OpenCode) (#5119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 6 of 7).** Depends on: - PR #5114 - PR #5115 - PR #5116 - PR #5117 - PR #5118 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The OpenCode adapter validates that its configured model exists before letting > a run start so misconfiguration fails fast with a clear error > - SSH testing reproduced an OpenCode failure where issues stayed `backlog`, > timed out, and produced no comments. The root cause was in > `packages/adapters/opencode-local/src/server/execute.ts`: the local model > guard `ensureOpenCodeModelConfiguredAndAvailable(...)` only ran when execution > was *not* remote, so SSH OpenCode bypassed it and failed silently later > - Subsequent testing surfaced a related remote-only failure where the probe > (when wired up naively) hits `EACCES: permission denied, mkdir '/var/folders'` > on the SSH box because of how OpenCode's runtime config picks a tempdir > - This PR runs the model probe on the actual execution target — `opencode > models` via `runAdapterExecutionTargetProcess` — instead of the local CLI, > parses the output with the shared `parseOpenCodeModelsOutput` helper, and > reports a concrete error naming the offending model and a sample of available > remote models when the configured model isn't present > - The benefit is that mismatched OpenCode models surface as a clear pre-flight > error referencing the remote target instead of a silent run that never leaves > `backlog` ## What Changed - Added `ensureRemoteOpenCodeModelConfiguredAndAvailable` in `opencode-local/src/server/execute.ts` that runs `opencode models` via `runAdapterExecutionTargetProcess` and validates the configured model is in the parsed output - `models.ts` now exports `parseOpenCodeModelsOutput` and `requireOpenCodeModelId` so the remote path can reuse them - `execute.ts` calls the remote variant when `executionTargetIsRemote`, otherwise the existing local `ensureOpenCodeModelConfiguredAndAvailable` - Errors include the offending model id and a sample of available remote models so the operator knows exactly what's missing - `execute.remote.test.ts` extended with cases for: probe timeout, probe non-zero exit, empty model list, and missing-model error ## Verification - `pnpm --filter @paperclipai/adapter-opencode-local test` - `pnpm test -- opencode-local` - Manual QA: configured an OpenCode agent with a model that exists locally but not in the remote sandbox, and confirmed the new error fires before the run starts and references the remote target ## Risks - New behaviour: remote model validation adds a `~20s timeout` `opencode models` call on every remote run start. For most environments this is fast, but a network-slow sandbox could see startup latency rise. Timeout is bounded. - If the remote CLI is missing or misconfigured, the new error replaces the old generic startup failure — clearer message, but the failure point shifts earlier. Monitor for any QA flows that relied on the old failure shape. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/server/execute.remote.test.ts | 127 +++++++++++++++--- .../opencode-local/src/server/execute.ts | 81 ++++++++++- .../opencode-local/src/server/models.ts | 4 +- 3 files changed, 186 insertions(+), 26 deletions(-) diff --git a/packages/adapters/opencode-local/src/server/execute.remote.test.ts b/packages/adapters/opencode-local/src/server/execute.remote.test.ts index a42879266da..8fc273791ee 100644 --- a/packages/adapters/opencode-local/src/server/execute.remote.test.ts +++ b/packages/adapters/opencode-local/src/server/execute.remote.test.ts @@ -13,23 +13,36 @@ const { syncDirectoryToSsh, startAdapterExecutionTargetPaperclipBridge, } = vi.hoisted(() => ({ - runChildProcess: vi.fn(async () => ({ - exitCode: 0, - signal: null, - timedOut: false, - stdout: [ - JSON.stringify({ type: "step_start", sessionID: "session_123" }), - JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }), - JSON.stringify({ - type: "step_finish", - sessionID: "session_123", - part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } }, - }), - ].join("\n"), - stderr: "", - pid: 123, - startedAt: new Date().toISOString(), - })), + runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]) => { + if (args.includes("models")) { + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: "opencode/gpt-5-nano\nopenai/gpt-4.1\n", + stderr: "", + pid: 122, + startedAt: new Date().toISOString(), + }; + } + return { + exitCode: 0, + signal: null, + timedOut: false, + stdout: [ + JSON.stringify({ type: "step_start", sessionID: "session_123" }), + JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }), + JSON.stringify({ + type: "step_finish", + sessionID: "session_123", + part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } }, + }), + ].join("\n"), + stderr: "", + pid: 123, + startedAt: new Date().toISOString(), + }; + }), ensureCommandResolvable: vi.fn(async () => undefined), resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: opencode"), prepareWorkspaceForSshExecution: vi.fn(async () => undefined), @@ -186,7 +199,18 @@ describe("opencode remote execution", () => { expect.stringContaining(".claude/skills"), expect.anything(), ); - const call = runChildProcess.mock.calls[0] as unknown as + const runCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + const modelProbeCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("models")) as + | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] + | undefined; + expect(modelProbeCall?.[2]).toEqual(["models"]); + expect(modelProbeCall?.[3].env.XDG_CONFIG_HOME).toBe( + "/remote/workspace/.paperclip-runtime/opencode/xdgConfig", + ); + expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace"); + const call = runCall as | [string, string, string[], { env: Record; remoteExecution?: { remoteCwd: string } | null }] | undefined; expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe("/remote/workspace"); @@ -211,6 +235,69 @@ describe("opencode remote execution", () => { expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1); }); + it("fails before the remote run when the configured model is unavailable on the SSH target", async () => { + runChildProcess.mockImplementationOnce(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "openai/gpt-4.1\n", + stderr: "", + pid: 456, + startedAt: new Date().toISOString(), + })); + + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-model-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + await expect(() => + execute({ + runId: "run-ssh-model-missing", + agent: { + id: "agent-1", + companyId: "company-1", + name: "OpenCode Builder", + adapterType: "opencode_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "opencode", + model: "opencode/gpt-5-nano", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }), + ).rejects.toThrow("Configured OpenCode model is unavailable on the remote execution target"); + + expect(runChildProcess).toHaveBeenCalledTimes(1); + expect((runChildProcess.mock.calls[0]?.[2] as string[] | undefined) ?? []).toEqual(["models"]); + expect(startAdapterExecutionTargetPaperclipBridge).not.toHaveBeenCalled(); + }); + it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-")); cleanupDirs.push(rootDir); @@ -267,7 +354,9 @@ describe("opencode remote execution", () => { onLog: async () => {}, }); - const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + const call = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as + | [string, string, string[]] + | undefined; expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("session-123"); }); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 6630ce986ef..015cd0c7ed1 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -42,7 +42,11 @@ import { resolvePaperclipDesiredSkillNames, } from "@paperclipai/adapter-utils/server-utils"; import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; -import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; +import { + ensureOpenCodeModelConfiguredAndAvailable, + parseOpenCodeModelsOutput, + requireOpenCodeModelId, +} from "./models.js"; import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; @@ -68,6 +72,64 @@ function resolveOpenCodeBiller(env: Record, provider: string | n return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown"; } +const REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC = 20; + +async function ensureRemoteOpenCodeModelConfiguredAndAvailable(input: { + runId: string; + executionTarget: NonNullable; + command: string; + model: string; + cwd: string; + env: Record; + timeoutSec: number; + graceSec: number; +}) { + const model = requireOpenCodeModelId(input.model); + const probeTimeoutSec = input.timeoutSec > 0 + ? Math.min(input.timeoutSec, REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC) + : REMOTE_OPENCODE_MODELS_PROBE_DEFAULT_TIMEOUT_SEC; + const probe = await runAdapterExecutionTargetProcess( + input.runId, + input.executionTarget, + input.command, + ["models"], + { + cwd: input.cwd, + env: input.env, + timeoutSec: probeTimeoutSec, + graceSec: input.graceSec, + onLog: async () => {}, + }, + ); + + if (probe.timedOut) { + throw new Error(`\`opencode models\` timed out on the remote execution target after ${probeTimeoutSec}s.`); + } + + if ((probe.exitCode ?? 1) !== 0) { + const detail = firstNonEmptyLine(probe.stderr) || firstNonEmptyLine(probe.stdout); + throw new Error( + detail + ? `\`opencode models\` failed on the remote execution target: ${detail}` + : "`opencode models` failed on the remote execution target.", + ); + } + + const models = parseOpenCodeModelsOutput(probe.stdout); + if (models.length === 0) { + throw new Error( + "OpenCode returned no models on the remote execution target. Run `opencode models` there and verify provider auth.", + ); + } + + if (!models.some((entry) => entry.id === model)) { + const sample = models.slice(0, 12).map((entry) => entry.id).join(", "); + throw new Error( + `Configured OpenCode model is unavailable on the remote execution target: ${model}. Available models: ${sample}${models.length > 12 ? ", ..." : ""}`, + ); + } +} + function claudeSkillsHome(): string { return path.join(os.homedir(), ".claude", "skills"); } @@ -247,6 +309,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; return asStringArray(config.args); })(); - const effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd); let restoreRemoteWorkspace: (() => Promise) | null = null; let localSkillsDir: string | null = null; let remoteRuntimeRootDir: string | null = null; let paperclipBridge: Awaited> = null; - if (executionTargetIsRemote) { + if (executionTarget?.kind === "remote") { localSkillsDir = await buildOpenCodeSkillsDir(config); await onLog( "stdout", @@ -321,6 +382,16 @@ export async function execute(ctx: AdapterExecutionContext): Promise Date: Sun, 3 May 2026 13:51:38 -0700 Subject: [PATCH 07/27] Avoid resuming stale remote sessions (Pi adapter) (#5120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > **Stacked PR (part 7 of 7).** Depends on: - PR #5114 - PR #5115 - PR #5116 - PR #5117 - PR #5118 - PR #5119 > Diff against `master` includes commits from earlier PRs in the stack — the new commit in this PR is the topmost one. ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The Pi adapter persists a session jsonl per agent so subsequent runs resume > conversation context instead of starting cold > - SSH testing reproduced a real failure: a verification issue reached terminal > `done` and the agent claimed success, but the proof artifact > `manual-qa/environment-matrix/ssh/pi_local.md` was missing from the realized > SSH workspace on the QA target box > - Root cause: the saved session header recorded a different cwd than the new > execution cwd, but the resume eligibility check only compared session-params > cwd via local-style `path.resolve` (which doesn't roundtrip on remote POSIX > paths). The stale session got resumed and writes landed in the wrong cwd > - This PR tightens resume eligibility for remote targets: it adds remote-aware > cwd normalisation, reads the first line of the session jsonl over SSH (`head > -n 1`) to verify the saved header cwd, and only resumes when both > session-params cwd *and* the on-disk header cwd match the realised execution > cwd. Stale sessions are skipped silently and the run starts cold > - The benefit is that Pi runs across cwd-changing environments stop > accidentally resuming each other's sessions, and proof artifacts land where > reviewers expect them ## What Changed - Added `normalizeExecutionCwd`, `executionCwdsMatch`, `readSessionHeaderCwd`, and `readSavedSessionCwd` helpers in `pi-local/src/server/execute.ts` - `readSavedSessionCwd` reads the first line of the session jsonl — locally via `fs.readFile`, remotely via `runAdapterExecutionTargetShellCommand` (`head -n 1`) - Resume eligibility now requires: 1. Saved session id is non-empty 2. Execution target shape matches (existing check) 3. Session-params cwd matches the realised execution cwd 4. Session-header cwd (from the on-disk jsonl) matches the realised execution cwd - Stale sessions are skipped silently (run starts cold) instead of resumed - `execute.remote.test.ts` extended with: matching header → resume; mismatched header → start fresh; missing/unreadable header → start fresh; remote head command failure → start fresh ## Verification - `pnpm --filter @paperclipai/adapter-pi-local test` - `pnpm test -- pi-local` - Manual QA: ran a Pi agent twice in two different remote cwds, confirmed the second run did not pick up the first run's session and that subsequent runs in the original cwd still resumed correctly ## Risks - Adds a `head -n 1` shell call per Pi run on remote targets. Negligible latency (single read of session jsonl), bounded by 15s timeout. - If the `head` call fails for unrelated reasons (transient remote unreachability), the run will start cold instead of resuming. This is the safe default but worth noting — operators may see one extra cold run if a remote glitches mid-session. - No data is deleted or migrated; stale sessions remain on disk for manual inspection if desired. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/server/execute.remote.test.ts | 230 ++++++++++++++++++ .../adapters/pi-local/src/server/execute.ts | 97 +++++++- 2 files changed, 323 insertions(+), 4 deletions(-) diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts index 9a36bad71d3..140ec366597 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -221,6 +221,22 @@ describe("pi remote execution", () => { const workspaceDir = path.join(rootDir, "workspace"); await mkdir(workspaceDir, { recursive: true }); + runSshCommand.mockImplementation(async (...args: unknown[]) => { + const command = String(args[1] ?? ""); + if (command.includes("head -n 1") && command.includes("session-123.jsonl")) { + return { + stdout: `${JSON.stringify({ type: "session", cwd: "/remote/workspace" })}\n`, + stderr: "", + exitCode: 0, + }; + } + return { + stdout: "", + stderr: "", + exitCode: 0, + }; + }); + await execute({ runId: "run-ssh-resume", agent: { @@ -275,4 +291,218 @@ describe("pi remote execution", () => { expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl"); }); + + it("starts a fresh remote Pi session when the saved session header cwd points at a different workspace", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-stale-session-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + runSshCommand.mockImplementation(async (...args: unknown[]) => { + const command = String(args[1] ?? ""); + if (command.includes("head -n 1") && command.includes("session-123.jsonl")) { + return { + stdout: `${JSON.stringify({ type: "session", cwd: "/remote/old-workspace" })}\n`, + stderr: "", + exitCode: 0, + }; + } + return { + stdout: "", + stderr: "", + exitCode: 0, + }; + }); + + await execute({ + runId: "run-ssh-stale-session", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Pi Builder", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + sessionParams: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { + command: "pi", + model: "openai/gpt-5.4-mini", + }, + context: { + paperclipWorkspace: { + cwd: workspaceDir, + source: "project_primary", + }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + const sessionIndex = call?.[2].indexOf("--session") ?? -1; + expect(sessionIndex).toBeGreaterThanOrEqual(0); + const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null; + expect(usedSession).toContain("/remote/workspace/.paperclip-runtime/pi/sessions/"); + expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl"); + }); + + it("starts a fresh remote Pi session when the saved session header is empty or unreadable", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-empty-header-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + runSshCommand.mockImplementation(async (...args: unknown[]) => { + const command = String(args[1] ?? ""); + if (command.includes("head -n 1") && command.includes("session-123.jsonl")) { + return { stdout: "", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); + + await execute({ + runId: "run-ssh-empty-header", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Pi Builder", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + sessionParams: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { command: "pi", model: "openai/gpt-5.4-mini" }, + context: { + paperclipWorkspace: { cwd: workspaceDir, source: "project_primary" }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + const sessionIndex = call?.[2].indexOf("--session") ?? -1; + expect(sessionIndex).toBeGreaterThanOrEqual(0); + const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null; + expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl"); + }); + + it("starts a fresh remote Pi session when the remote head command fails", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pi-remote-head-failure-")); + cleanupDirs.push(rootDir); + const workspaceDir = path.join(rootDir, "workspace"); + await mkdir(workspaceDir, { recursive: true }); + + runSshCommand.mockImplementation(async (...args: unknown[]) => { + const command = String(args[1] ?? ""); + if (command.includes("head -n 1") && command.includes("session-123.jsonl")) { + throw Object.assign(new Error("ssh: connect failed"), { + stdout: "", + stderr: "ssh: connect failed", + code: "ENOENT", + }); + } + return { stdout: "", stderr: "", exitCode: 0 }; + }); + + await execute({ + runId: "run-ssh-head-failure", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Pi Builder", + adapterType: "pi_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + sessionParams: { + sessionId: "/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl", + cwd: "/remote/workspace", + remoteExecution: { + transport: "ssh", + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteCwd: "/remote/workspace", + }, + }, + sessionDisplayId: "session-123", + taskKey: null, + }, + config: { command: "pi", model: "openai/gpt-5.4-mini" }, + context: { + paperclipWorkspace: { cwd: workspaceDir, source: "project_primary" }, + }, + executionTransport: { + remoteExecution: { + host: "127.0.0.1", + port: 2222, + username: "fixture", + remoteWorkspacePath: "/remote/workspace", + remoteCwd: "/remote/workspace", + privateKey: "PRIVATE KEY", + knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA", + strictHostKeyChecking: true, + }, + }, + onLog: async () => {}, + }); + + const call = runChildProcess.mock.calls[0] as unknown as [string, string, string[]] | undefined; + const sessionIndex = call?.[2].indexOf("--session") ?? -1; + expect(sessionIndex).toBeGreaterThanOrEqual(0); + const usedSession = sessionIndex >= 0 ? call?.[2][sessionIndex + 1] : null; + expect(usedSession).not.toBe("/remote/workspace/.paperclip-runtime/pi/sessions/session-123.jsonl"); + }); }); diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 1d1172b7014..f0b659b8896 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -17,6 +17,7 @@ import { readAdapterExecutionTarget, resolveAdapterExecutionTargetCommandForLogs, runAdapterExecutionTargetProcess, + runAdapterExecutionTargetShellCommand, startAdapterExecutionTargetPaperclipBridge, } from "@paperclipai/adapter-utils/execution-target"; import { @@ -41,6 +42,7 @@ import { DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; +import { shellQuote } from "@paperclipai/adapter-utils/ssh"; import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; import { ensurePiModelConfiguredAndAvailable } from "./models.js"; @@ -143,6 +145,68 @@ function buildRemoteSessionPath(runtimeRootDir: string, agentId: string, timesta return path.posix.join(runtimeRootDir, "sessions", `${safeTimestamp}-${agentId}.jsonl`); } +function normalizeExecutionCwd(candidate: string, remote: boolean): string { + return remote ? path.posix.normalize(candidate) : path.resolve(candidate); +} + +function executionCwdsMatch(saved: string, current: string, remote: boolean): boolean { + return normalizeExecutionCwd(saved, remote) === normalizeExecutionCwd(current, remote); +} + +function readSessionHeaderCwd(raw: string): string | null { + const headerLine = raw + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (!headerLine) return null; + try { + const parsed = JSON.parse(headerLine) as Record; + if (parsed.type !== "session") return null; + const cwd = typeof parsed.cwd === "string" ? parsed.cwd.trim() : ""; + return cwd.length > 0 ? cwd : null; + } catch { + return null; + } +} + +async function readSavedSessionCwd(input: { + runId: string; + sessionPath: string; + executionTarget: ReturnType; + cwd: string; + env: Record; + timeoutSec: number; + graceSec: number; +}): Promise { + if (!input.sessionPath.trim()) return null; + + if (!adapterExecutionTargetIsRemote(input.executionTarget)) { + try { + return readSessionHeaderCwd(await fs.readFile(input.sessionPath, "utf8")); + } catch { + return null; + } + } + + try { + const sessionHeader = await runAdapterExecutionTargetShellCommand( + input.runId, + input.executionTarget, + `if [ -f ${shellQuote(input.sessionPath)} ]; then head -n 1 ${shellQuote(input.sessionPath)}; fi`, + { + cwd: input.cwd, + env: input.env, + timeoutSec: input.timeoutSec > 0 ? Math.min(input.timeoutSec, 15) : 15, + graceSec: input.graceSec, + }, + ); + if (sessionHeader.timedOut || (sessionHeader.exitCode ?? 0) !== 0) return null; + return readSessionHeaderCwd(sessionHeader.stdout); + } catch { + return null; + } +} + export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx; const executionTarget = readAdapterExecutionTarget({ @@ -373,10 +437,31 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? await readSavedSessionCwd({ + runId, + sessionPath: runtimeSessionId, + executionTarget, + cwd, + env, + timeoutSec, + graceSec, + }) + : null; + const sessionHeaderCwdMatches = + runtimeSessionId.length === 0 || + (savedSessionCwd !== null && + executionCwdsMatch(savedSessionCwd, effectiveExecutionCwd, executionTargetIsRemote)); const canResumeSession = runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) && - adapterExecutionTargetSessionMatches(runtimeRemoteExecution, executionTarget); + sessionTargetMatches && + sessionParamsCwdMatches && + sessionHeaderCwdMatches; const sessionPath = canResumeSession ? runtimeSessionId : executionTargetIsRemote && remoteRuntimeRootDir @@ -384,11 +469,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise Date: Sun, 3 May 2026 18:33:55 -0700 Subject: [PATCH 08/27] Honor reuse-existing preference and assignee default environment in issue runs (#5139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents run inside execution workspaces (a per-issue cwd + env), and an issue > can prefer to reuse an existing workspace or get a fresh one each time > - The heartbeat service was reading the existing workspace's config to derive > environment selection regardless of whether the issue actually wanted to reuse > it. So fresh-run issues were inheriting stale config from a workspace that was > about to be discarded > - Separately, when an issue is assigned to an agent, the issue's execution > workspace settings weren't picking up the agent's `defaultEnvironmentId`, > even though the agent's choice is the natural default for that issue > - This PR makes both selection paths honor the obvious source of truth: > workspace config flows only when the issue actually wants `reuse_existing`, > and the assignee agent's default environment is applied at assignment time if > nothing else is set on the issue > - The benefit is that re-running a flaky issue picks up the right environment > instead of inheriting the previous run's config, and assigning an agent to an > issue does the obvious thing without operator intervention ## What Changed - `server/src/services/heartbeat.ts`: introduce `reusableExecutionWorkspaceConfig` that is non-null only when `shouldReuseExisting` is true. Both `resolveExecutionWorkspaceEnvironmentId(...)` and `applyPersistedExecutionWorkspaceConfig(...)` now read from it instead of unconditionally consulting `existingExecutionWorkspace?.config`. Fresh-run issues no longer inherit stale environment config from an in-flight workspace about to be discarded. - `server/src/services/issues.ts`: when an issue update sets a new `assigneeAgentId` and isolated workspaces are enabled, populate `executionWorkspaceSettings.environmentId` from the assignee agent's `defaultEnvironmentId` if the issue doesn't have an explicit `environmentId` set yet. - Tests added in `heartbeat-plugin-environment.test.ts` (~216 lines) and `issues-service.test.ts` (~85 lines) covering both paths. ## Verification - `pnpm --filter @paperclipai/server test -- heartbeat-plugin-environment issues-service` - Manual QA: assign an issue to an agent that has a non-default `defaultEnvironmentId`, confirm the issue's workspace settings now include that environment id without operator intervention. Trigger a rerun on an issue whose existing workspace points at a stale environment, confirm the rerun uses the freshly-resolved environment. ## Risks - Behavioural shift on assignment: previously assigning an agent didn't propagate the agent's default environment to the issue. Now it does. Callers that explicitly want the issue to keep its existing/null environment must set `executionWorkspaceSettings.environmentId` themselves; the new logic only fires when no explicit value is set. - Behavioural shift on rerun: stale workspace config is no longer applied to fresh runs. Operators who relied on this implicit inheritance may see different environment selection on the first rerun after deploy. Mitigation: the explicit isssue settings and project policy are still honored as before. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A (no UI changes) - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../heartbeat-plugin-environment.test.ts | 216 ++++++++++- server/src/__tests__/issues-service.test.ts | 346 ++++++++++++++++++ server/src/services/heartbeat.ts | 7 +- server/src/services/issues.ts | 145 +++++++- 4 files changed, 700 insertions(+), 14 deletions(-) diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index ac9fdb4275b..46de3f47f42 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -8,6 +8,8 @@ import { companies, createDb, environments, + executionWorkspaces, + issues, plugins, projects, projectWorkspaces, @@ -17,6 +19,7 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; import { heartbeatService } from "../services/heartbeat.ts"; +import { instanceSettingsService } from "../services/instance-settings.ts"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.ts"; const adapterExecute = vi.hoisted(() => vi.fn(async () => ({ @@ -76,6 +79,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { const workspaceId = randomUUID(); const environmentId = randomUUID(); const pluginId = randomUUID(); + const pluginKey = `acme.environments.${pluginId}`; const agentId = randomUUID(); const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-heartbeat-")); tempRoots.push(workspaceRoot); @@ -100,6 +104,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { await db.insert(companies).values({ id: companyId, name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, status: "active", createdAt: new Date(), updatedAt: new Date(), @@ -124,13 +129,13 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { }); await db.insert(plugins).values({ id: pluginId, - pluginKey: "acme.environments", + pluginKey, packageName: "@acme/paperclip-environments", version: "1.0.0", apiVersion: 1, categories: ["automation"], manifestJson: { - id: "acme.environments", + id: pluginKey, apiVersion: 1, version: "1.0.0", displayName: "Acme Environments", @@ -157,8 +162,8 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { name: "Plugin Sandbox", driver: "plugin", status: "active", - config: { - pluginKey: "acme.environments", + config: { + pluginKey, driverKey: "sandbox", driverConfig: { template: "base", @@ -213,11 +218,210 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { leaseMetadata: expect.objectContaining({ driver: "plugin", pluginId, - pluginKey: "acme.environments", + pluginKey, driverKey: "sandbox", }), }); }, { timeout: 5_000 }); expect(adapterExecute).toHaveBeenCalledTimes(1); - }); + }, 15_000); + + it("ignores stale non-reused workspace environment config in favor of the issue selection", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const workspaceId = randomUUID(); + const oldEnvironmentId = randomUUID(); + const newEnvironmentId = randomUUID(); + const pluginId = randomUUID(); + const pluginKey = `acme.environments.${pluginId}`; + const agentId = randomUUID(); + const issueId = randomUUID(); + const staleExecutionWorkspaceId = randomUUID(); + const workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-env-issue-")); + tempRoots.push(workspaceRoot); + const workerManager = { + isRunning: vi.fn((id: string) => id === pluginId), + call: vi.fn(async (_pluginId: string, method: string, payload: Record) => { + if (method === "environmentAcquireLease") { + return { + providerLeaseId: `plugin-heartbeat-lease-${String(payload.environmentId)}`, + metadata: { + remoteCwd: `/workspace/${String(payload.environmentId)}`, + }, + }; + } + if (method === "environmentReleaseLease") { + return undefined; + } + throw new Error(`Unexpected plugin environment method: ${method}`); + }), + } as unknown as PluginWorkerManager; + + await instanceSettingsService(db).updateExperimental({ + enableEnvironments: true, + enableIsolatedWorkspaces: true, + }); + await db.insert(companies).values({ + id: companyId, + name: "Acme", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Plugin Environment Issue", + status: "active", + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projectWorkspaces).values({ + id: workspaceId, + companyId, + projectId, + name: "Primary", + cwd: workspaceRoot, + isPrimary: true, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey, + packageName: "@acme/paperclip-environments", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: pluginKey, + apiVersion: 1, + version: "1.0.0", + displayName: "Acme Environments", + description: "Test plugin environment driver", + author: "Acme", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "sandbox", + displayName: "Sandbox", + configSchema: { type: "object" }, + }, + ], + }, + status: "ready", + installOrder: 1, + updatedAt: new Date(), + } as any); + await db.insert(environments).values([ + { + id: oldEnvironmentId, + companyId, + name: "QA SSH", + driver: "plugin", + status: "active", + config: { + pluginKey, + driverKey: "sandbox", + driverConfig: { + template: "old", + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: newEnvironmentId, + companyId, + name: "QA E2B", + driver: "plugin", + status: "active", + config: { + pluginKey, + driverKey: "sandbox", + driverConfig: { + template: "new", + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: oldEnvironmentId, + permissions: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(executionWorkspaces).values({ + id: staleExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: workspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Stale workspace", + status: "active", + cwd: workspaceRoot, + providerType: "local_fs", + providerRef: workspaceRoot, + metadata: { + config: { + environmentId: oldEnvironmentId, + }, + }, + createdAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(issues).values({ + id: issueId, + companyId, + projectId, + projectWorkspaceId: workspaceId, + title: "Environment matrix: e2b / codex_local", + status: "in_progress", + priority: "medium", + assigneeAgentId: agentId, + executionWorkspaceId: staleExecutionWorkspaceId, + executionWorkspaceSettings: { + mode: "shared_workspace", + environmentId: newEnvironmentId, + }, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const heartbeat = heartbeatService(db, { pluginWorkerManager: workerManager }); + const run = await heartbeat.wakeup(agentId, { + source: "assignment", + triggerDetail: "manual", + contextSnapshot: { issueId }, + }); + + expect(run).not.toBeNull(); + await vi.waitFor(async () => { + const latest = await heartbeat.getRun(run!.id); + expect(latest?.status).toBe("succeeded"); + }, { timeout: 5_000 }); + + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "environmentAcquireLease", { + driverKey: "sandbox", + companyId, + environmentId: newEnvironmentId, + config: { template: "new" }, + runId: run!.id, + workspaceMode: "shared_workspace", + }); + }, 15_000); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ad11eecdd7b..3e2ab7940fa 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -7,6 +7,7 @@ import { agents, companies, createDb, + environments, executionWorkspaces, goals, heartbeatRuns, @@ -1184,6 +1185,351 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { }); }); + it("captures the assignee default environment when neither issue nor project specifies one", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const assigneeEnvironmentId = randomUUID(); + const assigneeAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { + id: assigneeEnvironmentId, + companyId, + name: "QA E2B", + driver: "sandbox", + status: "active", + config: { provider: "e2b" }, + }, + ]); + + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "QA E2B Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: assigneeEnvironmentId, + permissions: {}, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + const issue = await svc.create(companyId, { + projectId, + assigneeAgentId, + title: "Environment matrix: e2b / codex_local", + status: "todo", + priority: "medium", + }); + + expect(issue.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + environmentId: assigneeEnvironmentId, + }); + }); + + it("does not promote the assignee default environment when the project policy already specifies one", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const projectEnvironmentId = randomUUID(); + const assigneeEnvironmentId = randomUUID(); + const assigneeAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { + id: projectEnvironmentId, + companyId, + name: "QA SSH", + driver: "ssh", + status: "active", + config: {}, + }, + { + id: assigneeEnvironmentId, + companyId, + name: "QA E2B", + driver: "sandbox", + status: "active", + config: { provider: "e2b" }, + }, + ]); + + await db.insert(agents).values({ + id: assigneeAgentId, + companyId, + name: "QA E2B Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: assigneeEnvironmentId, + permissions: {}, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + environmentId: projectEnvironmentId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + const issue = await svc.create(companyId, { + projectId, + assigneeAgentId, + title: "Environment matrix: e2b / codex_local", + status: "todo", + priority: "medium", + }); + + // Project policy's environmentId must win over the assignee's default; + // executionWorkspaceSettings should not bake in an environmentId in this case + // so resolveExecutionWorkspaceEnvironmentId can fall through to the project + // policy's value at run time. + expect(issue.executionWorkspaceSettings).toEqual({ mode: "shared_workspace" }); + }); + + it("captures the new assignee's default environment on reassignment", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const firstEnvironmentId = randomUUID(); + const secondEnvironmentId = randomUUID(); + const firstAgentId = randomUUID(); + const secondAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { + id: firstEnvironmentId, + companyId, + name: "QA SSH", + driver: "ssh", + status: "active", + config: {}, + }, + { + id: secondEnvironmentId, + companyId, + name: "QA E2B", + driver: "sandbox", + status: "active", + config: { provider: "e2b" }, + }, + ]); + + await db.insert(agents).values([ + { + id: firstAgentId, + companyId, + name: "QA SSH Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: firstEnvironmentId, + permissions: {}, + }, + { + id: secondAgentId, + companyId, + name: "QA E2B Codex", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + defaultEnvironmentId: secondEnvironmentId, + permissions: {}, + }, + ]); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + }); + + const created = await svc.create(companyId, { + projectId, + assigneeAgentId: firstAgentId, + title: "Environment matrix: ssh / codex_local", + status: "todo", + priority: "medium", + }); + + expect(created.executionWorkspaceSettings).toMatchObject({ + environmentId: firstEnvironmentId, + }); + + const reassigned = await svc.update(created.id, { + assigneeAgentId: secondAgentId, + }); + + expect(reassigned).not.toBeNull(); + expect(reassigned!.executionWorkspaceSettings).toMatchObject({ + environmentId: secondEnvironmentId, + }); + }); + + it("preserves an operator-set environmentId across reassignment", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const firstEnvironmentId = randomUUID(); + const secondEnvironmentId = randomUUID(); + const operatorEnvironmentId = randomUUID(); + const firstAgentId = randomUUID(); + const secondAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(environments).values([ + { id: firstEnvironmentId, companyId, name: "Env 1", driver: "ssh", status: "active", config: {} }, + { id: secondEnvironmentId, companyId, name: "Env 2", driver: "sandbox", status: "active", config: { provider: "e2b" } }, + { id: operatorEnvironmentId, companyId, name: "Operator pick", driver: "ssh", status: "active", config: {} }, + ]); + + await db.insert(agents).values([ + { + id: firstAgentId, companyId, name: "First agent", role: "engineer", status: "active", + adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: firstEnvironmentId, permissions: {}, + }, + { + id: secondAgentId, companyId, name: "Second agent", role: "engineer", status: "active", + adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, + defaultEnvironmentId: secondEnvironmentId, permissions: {}, + }, + ]); + + await db.insert(projects).values({ + id: projectId, companyId, name: "Workspace project", status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + defaultMode: "shared_workspace", + allowIssueOverride: true, + defaultProjectWorkspaceId: projectWorkspaceId, + }, + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, companyId, projectId, name: "Primary workspace", isPrimary: true, + }); + + const created = await svc.create(companyId, { + projectId, + assigneeAgentId: firstAgentId, + title: "Operator overrides env then reassigns", + status: "todo", + priority: "medium", + }); + + // Operator explicitly overrides the environmentId in a separate update. + const overridden = await svc.update(created.id, { + executionWorkspaceSettings: { + mode: "shared_workspace", + environmentId: operatorEnvironmentId, + }, + }); + expect(overridden!.executionWorkspaceSettings).toMatchObject({ + environmentId: operatorEnvironmentId, + }); + + // A subsequent reassignment-only update must NOT overwrite the operator's + // explicit choice with the new assignee's default. + const reassigned = await svc.update(created.id, { + assigneeAgentId: secondAgentId, + }); + expect(reassigned!.executionWorkspaceSettings).toMatchObject({ + environmentId: operatorEnvironmentId, + }); + }); + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 02ae14d2188..71032747215 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -6320,6 +6320,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueRef?.executionWorkspacePreference === "reuse_existing" && existingExecutionWorkspace !== null && existingExecutionWorkspace.status !== "archived"; + const reusableExecutionWorkspaceConfig = shouldReuseExisting + ? existingExecutionWorkspace?.config ?? null + : null; const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) : null; @@ -6333,7 +6336,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) const selectedEnvironmentId = resolveExecutionWorkspaceEnvironmentId({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, - workspaceConfig: existingExecutionWorkspace?.config ?? null, + workspaceConfig: reusableExecutionWorkspaceConfig, agentDefaultEnvironmentId: agent.defaultEnvironmentId, defaultEnvironmentId: defaultEnvironment.id, }); @@ -6348,7 +6351,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }); const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ config: workspaceManagedConfig, - workspaceConfig: existingExecutionWorkspace?.config ?? null, + workspaceConfig: reusableExecutionWorkspaceConfig, mode: effectiveExecutionWorkspaceMode, }); let adapterModelProfiles: AdapterModelProfileDefinition[] = []; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 8f49238d876..48ddc5616b3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -35,6 +35,7 @@ import type { } from "@paperclipai/shared"; import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; +import { parseObject } from "../adapters/utils.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, @@ -2733,24 +2734,68 @@ export function issueService(db: Db) { } } } + // Cache the project policy lookup for this insert. Both the + // default-settings block and the assignee-environment-promotion block + // need the same row; without caching they'd issue two round-trips. + let projectPolicyCached: ReturnType | null = null; + let projectPolicyLoaded = false; + const loadProjectPolicyOnce = async () => { + if (projectPolicyLoaded) return projectPolicyCached; + projectPolicyLoaded = true; + if (!issueData.projectId) return null; + const projectRow = await tx + .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects) + .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + projectPolicyCached = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy); + return projectPolicyCached; + }; + if ( executionWorkspaceSettings == null && executionWorkspaceId == null && issueData.projectId ) { - const project = await tx - .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) - .from(projects) - .where(and(eq(projects.id, issueData.projectId), eq(projects.companyId, companyId))) - .then((rows) => rows[0] ?? null); executionWorkspaceSettings = defaultIssueExecutionWorkspaceSettingsForProject( gateProjectExecutionWorkspacePolicy( - parseProjectExecutionWorkspacePolicy(project?.executionWorkspacePolicy), + await loadProjectPolicyOnce(), isolatedWorkspacesEnabled, ), ) as Record | null; } + if (data.assigneeAgentId && isolatedWorkspacesEnabled) { + const currentWorkspaceSettings = executionWorkspaceSettings == null + ? {} + : parseObject(executionWorkspaceSettings); + const issueHasEnvironmentSelection = + Object.prototype.hasOwnProperty.call(currentWorkspaceSettings, "environmentId"); + // Don't promote the assignee agent's defaultEnvironmentId if either + // the issue or the project policy already specifies an environment. + // resolveExecutionWorkspaceEnvironmentId treats issue settings as + // higher priority than project policy, so promoting the agent's + // default to issue settings would invert the documented priority + // (project policy must win over agent default when explicitly set). + let projectHasEnvironmentSelection = false; + if (!issueHasEnvironmentSelection && issueData.projectId) { + const projectPolicy = await loadProjectPolicyOnce(); + projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined; + } + if (!issueHasEnvironmentSelection && !projectHasEnvironmentSelection) { + const assigneeAgent = await tx + .select({ defaultEnvironmentId: agents.defaultEnvironmentId }) + .from(agents) + .where(and(eq(agents.id, data.assigneeAgentId), eq(agents.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (typeof assigneeAgent?.defaultEnvironmentId === "string" && assigneeAgent.defaultEnvironmentId.length > 0) { + executionWorkspaceSettings = { + ...currentWorkspaceSettings, + environmentId: assigneeAgent.defaultEnvironmentId, + }; + } + } + } if (!projectWorkspaceId && issueData.projectId) { const project = await tx .select({ @@ -2978,6 +3023,94 @@ export function issueService(db: Db) { issueData.projectId !== undefined ? issueData.projectId : existing.projectId, ), ]); + + // Mirror the create() path: when the assignee changes to a non-null + // agent, default the issue's executionWorkspaceSettings.environmentId + // to the new agent's defaultEnvironmentId. Skip when: + // - this update explicitly sets executionWorkspaceSettings.environmentId + // (caller is making a deliberate override; respect it), OR + // - the project policy already specifies an environmentId (project + // policy must win over agent default per the documented priority + // order in resolveExecutionWorkspaceEnvironmentId), OR + // - the issue already has an environmentId that was *not* the prior + // assignee's default (i.e., the operator set it explicitly in an + // earlier update; preserve their choice). When the existing + // environmentId matches the prior assignee's default, treat it as + // auto-promoted and refresh it to the new assignee's default. + const assigneeChanged = + issueData.assigneeAgentId !== undefined && + issueData.assigneeAgentId !== null && + issueData.assigneeAgentId !== existing.assigneeAgentId; + const explicitEnvInThisUpdate = + issueData.executionWorkspaceSettings !== undefined && + Object.prototype.hasOwnProperty.call( + parseObject(issueData.executionWorkspaceSettings), + "environmentId", + ); + if (assigneeChanged && isolatedWorkspacesEnabled && !explicitEnvInThisUpdate) { + let projectHasEnvironmentSelection = false; + if (nextProjectId) { + const projectRow = await tx + .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projects) + .where(and(eq(projects.id, nextProjectId), eq(projects.companyId, existing.companyId))) + .then((rows: Array<{ executionWorkspacePolicy: unknown }>) => rows[0] ?? null); + const projectPolicy = parseProjectExecutionWorkspacePolicy(projectRow?.executionWorkspacePolicy); + projectHasEnvironmentSelection = projectPolicy?.environmentId !== undefined; + } + if (!projectHasEnvironmentSelection) { + const baseSettings = nextExecutionWorkspaceSettings == null + ? {} + : parseObject(nextExecutionWorkspaceSettings); + const existingEnvId = typeof baseSettings.environmentId === "string" + ? baseSettings.environmentId + : null; + + // Look up both the prior assignee (to detect auto-promoted env) + // and the new assignee in a single query. + type AgentRow = { id: string; defaultEnvironmentId: string | null }; + const agentRows: AgentRow[] = await tx + .select({ id: agents.id, defaultEnvironmentId: agents.defaultEnvironmentId }) + .from(agents) + .where( + and( + eq(agents.companyId, existing.companyId), + inArray( + agents.id, + [issueData.assigneeAgentId!, existing.assigneeAgentId].filter( + (value): value is string => typeof value === "string", + ), + ), + ), + ); + + const newAssignee = agentRows.find((row: AgentRow) => row.id === issueData.assigneeAgentId); + const previousAssignee = existing.assigneeAgentId + ? agentRows.find((row: AgentRow) => row.id === existing.assigneeAgentId) + : null; + + const newDefaultEnvId = + typeof newAssignee?.defaultEnvironmentId === "string" && newAssignee.defaultEnvironmentId.length > 0 + ? newAssignee.defaultEnvironmentId + : null; + const previousDefaultEnvId = + typeof previousAssignee?.defaultEnvironmentId === "string" && previousAssignee.defaultEnvironmentId.length > 0 + ? previousAssignee.defaultEnvironmentId + : null; + + const existingEnvWasAutoPromoted = + existingEnvId === null || + (previousDefaultEnvId !== null && existingEnvId === previousDefaultEnvId); + + if (newDefaultEnvId && existingEnvWasAutoPromoted) { + patch.executionWorkspaceSettings = { + ...baseSettings, + environmentId: newDefaultEnvId, + }; + } + } + } + patch.goalId = resolveNextIssueGoalId({ currentProjectId: existing.projectId, currentGoalId: existing.goalId, From 2dce81fbf62c6767fd297b235ccc3fdd7db4f118 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 18:34:48 -0700 Subject: [PATCH 09/27] Add optional bridge proxy request logging via PAPERCLIP_BRIDGE_DEBUG (#5140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents on remote sandboxes call back into the Paperclip control plane via a > callback bridge — the host process running the bridge proxies HTTP requests > from the sandbox to the Paperclip API > - When something goes wrong end-to-end (sandbox can't reach Paperclip, requests > timing out, malformed responses), it's hard to tell whether the bridge > processed the request, what URL/method it used, and what the upstream > responded with > - There was no built-in way to log bridge proxy traffic without modifying > adapter code or attaching a debugger > - This PR adds opt-in stdout logging of every bridge proxy request and response > (method, path, query, status), gated behind `PAPERCLIP_BRIDGE_DEBUG` so it > stays off by default > - The benefit is that operators can flip a single env var to get full visibility > into bridge traffic when debugging remote runs, without changing code ## What Changed - `packages/adapter-utils/src/execution-target.ts`: `startAdapterExecutionTargetPaperclipBridge`'s `handleRequest` now logs each proxied request and response when `PAPERCLIP_BRIDGE_DEBUG` is truthy: - `[paperclip] Bridge proxy ?` before fetch - `[paperclip] Bridge proxy response for ?` after - Logging is no-op when the env var is unset/`"0"`/`"false"`. ## Verification - Set `PAPERCLIP_BRIDGE_DEBUG=1` in the host process env, run an agent against a sandbox-backed environment, confirm the bridge log lines appear in stdout. - Unset the env var and confirm no extra log lines appear during normal runs. ## Risks - Off-by-default, no observable change for shipping users. - When enabled, the logging is verbose — every API call from the sandbox produces 2 stdout lines. Operators should only enable it during active debugging. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [ ] I have added or updated tests where applicable — covered by exercising the flag in dev; the underlying handleRequest behavior is unchanged - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapter-utils/src/execution-target.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index e0b02730179..6372e08d554 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -125,6 +125,11 @@ function resolveDefaultPaperclipApiUrl(): string { return `http://${runtimeHost}:${runtimePort}`; } +function isBridgeDebugEnabled(env: NodeJS.ProcessEnv): boolean { + const value = env.PAPERCLIP_BRIDGE_DEBUG?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} + function isAdapterExecutionTargetInstance(value: unknown): value is AdapterExecutionTarget { const parsed = parseObject(value); if (parsed.kind === "local") return true; @@ -743,11 +748,25 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { timeoutMs: adapterExecutionTargetTimeoutMs(target), shellCommand, }); + // PAPERCLIP_BRIDGE_DEBUG opts into verbose stdout logs of every bridge + // proxy request/response. The query string is logged verbatim, so callers + // who pass auth tokens or other sensitive values as query parameters + // should be aware those values appear in the host process's stdout when + // this flag is enabled. Only intended for active debugging in trusted + // environments. + const bridgeDebugEnabled = isBridgeDebugEnabled(process.env); worker = await startSandboxCallbackBridgeWorker({ client, queueDir, maxBodyBytes, handleRequest: async (request) => { + const method = request.method.trim().toUpperCase() || "GET"; + if (bridgeDebugEnabled) { + await onLog( + "stdout", + `[paperclip] Bridge proxy ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`, + ); + } const headers = new Headers(); for (const [key, value] of Object.entries(request.headers)) { if (value.trim().length === 0) continue; @@ -755,13 +774,18 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: { } headers.set("authorization", `Bearer ${hostApiToken}`); headers.set("x-paperclip-run-id", input.runId); - const method = request.method.trim().toUpperCase() || "GET"; const response = await fetch(buildBridgeForwardUrl(hostApiUrl, request), { method, headers, ...(method === "GET" || method === "HEAD" ? {} : { body: request.body }), signal: AbortSignal.timeout(30_000), }); + if (bridgeDebugEnabled) { + await onLog( + "stdout", + `[paperclip] Bridge proxy response ${response.status} for ${method} ${request.path}${request.query ? `?${request.query}` : ""}\n`, + ); + } return { status: response.status, headers: buildBridgeResponseHeaders(response), From 90631b09b36fa028ad24ca5375bfa50e3602799c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 18:35:36 -0700 Subject: [PATCH 10/27] Let adapters declare runtime command spec for remote provisioning (#5141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, running adapter > commands like `claude`, `codex`, `pi` either locally or on remote runtimes > (SSH hosts, sandboxes, etc.) > - On a fresh remote runtime — particularly an ephemeral sandbox — the > adapter's CLI may not be installed yet. Today operators handle this via > external configuration (e.g. a project-level `provisionCommand` shell > script) that has to know about every adapter the operator might want to use > - This means every adapter has its own well-known npm package, but operators > end up writing duplicate provision shell scripts that paste together > `npm install -g @anthropic-ai/claude-code`, `npm install -g @openai/codex`, > etc. — knowledge the adapter itself already has > - This PR moves that knowledge into the adapter modules: each adapter declares > how its runtime command should be detected and (if applicable) installed > via `getRuntimeCommandSpec(config)`. The execution path runs the adapter's > own install command on remote sandbox targets before launching, so a fresh > sandbox bootstraps itself instead of requiring a hand-written provision script > - The benefit is fewer footguns for operators provisioning remote runtimes, > and a clean place for new adapters to plug in their install recipe ## What Changed - New types in `packages/adapter-utils/src/types.ts`: - `AdapterRuntimeCommandSpec` describing `command`, optional `detectCommand`, and optional `installCommand` - Optional `getRuntimeCommandSpec(config)` on `ServerAdapterModule` - Optional `runtimeCommandSpec` on `AdapterExecutionContext` so adapters receive the resolved spec at execute time - New helper `ensureAdapterExecutionTargetRuntimeCommandInstalled(...)` in `packages/adapter-utils/src/execution-target.ts` that runs the install command on remote targets when `transport === "sandbox"`. SSH and local targets are no-ops. Throws on timeout or non-zero exit so failures surface early. - Each of `claude-local`, `codex-local`, `cursor-local`, `gemini-local`, `opencode-local`, `pi-local`'s `execute.ts` now reads `ctx.runtimeCommandSpec?.installCommand` and calls the helper before launching the adapter command. - `server/src/adapters/registry.ts` declares `getRuntimeCommandSpec` for each adapter: - claude/codex/gemini/opencode/pi-local: `npm install -g ` recipe via a shared `buildNpmRuntimeCommandSpec` helper, with a defensive guard that only auto-installs when the configured `command` matches the well-known fallback (custom binaries are left alone). - cursor-local: declares `command` only; no auto-install (no public npm package), preserving the existing manual setup. - `server/src/services/heartbeat.ts` resolves the spec via `adapter.getRuntimeCommandSpec?.(runtimeConfig)` and passes it through to `AdapterExecutionContext`. - Tests added in `execution-target.test.ts` (~75 lines), e2b `plugin.test.ts` (~32 lines), and `environment-run-orchestrator.test.ts` (~76 lines). ## Verification - `pnpm --filter @paperclipai/adapter-utils test` - `pnpm --filter @paperclipai/server test -- environment-run-orchestrator` - `pnpm --filter @paperclipai/sandbox-providers-e2b test` - Manual QA: run an adapter (claude/codex/etc.) against a fresh sandbox-backed environment that does NOT have the adapter CLI pre-installed. Confirm the install runs once at the start of the agent run and the adapter then launches successfully. Re-run on the same sandbox; confirm the install command is idempotent and the second run starts faster. - Confirm SSH and local execution paths are unaffected (gated by `transport === "sandbox"`). ## Risks - Behavioural shift on sandbox runs: a new install step now runs at the start of every sandbox agent run for adapters with `installCommand` set. The install commands are idempotent (`if ! command -v X >/dev/null 2>&1; then npm install -g ; fi`), so this is fast on warm sandboxes. On a cold sandbox, the first run takes longer. - Operators who used the legacy project-level `provisionCommand` to install adapter CLIs can drop that part of their script; the adapter handles it now. Existing scripts continue to work — installs are idempotent. - The cursor-local adapter has no auto-install (no public npm package). Behaviour for cursor-local on sandboxes is unchanged. - New optional surface on `ServerAdapterModule`. Plugins that don't implement `getRuntimeCommandSpec` retain previous behaviour (no auto-install). ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../src/execution-target.test.ts | 75 ++++++++++++++++++ .../adapter-utils/src/execution-target.ts | 54 +++++++++++++ packages/adapter-utils/src/index.ts | 1 + packages/adapter-utils/src/types.ts | 23 ++++++ .../claude-local/src/server/execute.ts | 29 ++++++- .../codex-local/src/server/execute.ts | 18 ++++- .../cursor-local/src/server/execute.ts | 12 +++ .../gemini-local/src/server/execute.ts | 22 +++++- .../opencode-local/src/server/execute.ts | 17 +++- .../adapters/pi-local/src/server/execute.ts | 16 +++- .../environment-run-orchestrator.test.ts | 79 +++++++++++++++++++ server/src/adapters/registry.ts | 49 +++++++++++- server/src/adapters/types.ts | 1 + server/src/services/heartbeat.ts | 1 + 14 files changed, 383 insertions(+), 14 deletions(-) diff --git a/packages/adapter-utils/src/execution-target.test.ts b/packages/adapter-utils/src/execution-target.test.ts index 8a3b1ddb335..71bcc34499d 100644 --- a/packages/adapter-utils/src/execution-target.test.ts +++ b/packages/adapter-utils/src/execution-target.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as ssh from "./ssh.js"; import { adapterExecutionTargetUsesManagedHome, + ensureAdapterExecutionTargetRuntimeCommandInstalled, resolveAdapterExecutionTargetCwd, runAdapterExecutionTargetShellCommand, } from "./execution-target.js"; @@ -161,6 +162,80 @@ describe("runAdapterExecutionTargetShellCommand", () => { }); }); +describe("ensureAdapterExecutionTargetRuntimeCommandInstalled", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("runs install commands for sandbox targets", async () => { + const runner = { + execute: vi.fn(async () => ({ + exitCode: 0, + signal: null, + timedOut: false, + stdout: "", + stderr: "", + pid: null, + startedAt: new Date().toISOString(), + })), + }; + + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId: "run-install", + target: { + kind: "remote", + transport: "sandbox", + providerKey: "e2b", + remoteCwd: "/remote/workspace", + runner, + }, + installCommand: "npm install -g @google/gemini-cli", + cwd: "/local/workspace", + env: { PATH: "/usr/bin" }, + timeoutSec: 30, + }); + + expect(runner.execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "sh", + args: ["-lc", "npm install -g @google/gemini-cli"], + cwd: "/remote/workspace", + env: { PATH: "/usr/bin" }, + timeoutMs: 30_000, + })); + }); + + it("skips install commands for SSH targets", async () => { + const runSshCommandSpy = vi.spyOn(ssh, "runSshCommand").mockResolvedValue({ + stdout: "", + stderr: "", + }); + + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId: "run-skip", + target: { + kind: "remote", + transport: "ssh", + remoteCwd: "/srv/paperclip/workspace", + spec: { + host: "ssh.example.test", + port: 22, + username: "ssh-user", + remoteCwd: "/srv/paperclip/workspace", + remoteWorkspacePath: "/srv/paperclip/workspace", + privateKey: null, + knownHosts: null, + strictHostKeyChecking: true, + }, + }, + installCommand: "npm install -g @google/gemini-cli", + cwd: "/tmp/local", + env: {}, + }); + + expect(runSshCommandSpy).not.toHaveBeenCalled(); + }); +}); + describe("resolveAdapterExecutionTargetCwd", () => { const sshTarget = { kind: "remote" as const, diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 6372e08d554..398f4899f44 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -393,6 +393,60 @@ export async function readAdapterExecutionTargetHomeDir( return homeDir.length > 0 ? homeDir : null; } +export async function ensureAdapterExecutionTargetRuntimeCommandInstalled(input: { + runId: string; + target: AdapterExecutionTarget | null | undefined; + installCommand?: string | null; + detectCommand?: string | null; + cwd: string; + env: Record; + timeoutSec?: number; + graceSec?: number; + onLog?: AdapterExecutionTargetShellOptions["onLog"]; +}): Promise { + const installCommand = input.installCommand?.trim(); + if (!installCommand || input.target?.kind !== "remote" || input.target.transport !== "sandbox") { + return; + } + + const detectCommand = input.detectCommand?.trim(); + if (detectCommand) { + const probe = await runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + `command -v ${shellQuote(detectCommand)} >/dev/null 2>&1`, + { + cwd: input.cwd, + env: input.env, + timeoutSec: input.timeoutSec, + graceSec: input.graceSec, + }, + ); + if (!probe.timedOut && probe.exitCode === 0) { + return; + } + } + + const result = await runAdapterExecutionTargetShellCommand( + input.runId, + input.target, + installCommand, + { + cwd: input.cwd, + env: input.env, + timeoutSec: input.timeoutSec, + graceSec: input.graceSec, + onLog: input.onLog, + }, + ); + if (result.timedOut) { + throw new Error(`Timed out while installing the adapter runtime command via: ${installCommand}`); + } + if ((result.exitCode ?? 0) !== 0) { + throw new Error(`Failed to install the adapter runtime command via: ${installCommand}`); + } +} + export async function ensureAdapterExecutionTargetFile( runId: string, target: AdapterExecutionTarget | null | undefined, diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 0c144b7ad75..4ebcb07665e 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -27,6 +27,7 @@ export type { ConfigFieldOption, ConfigFieldSchema, AdapterConfigSchema, + AdapterRuntimeCommandSpec, ServerAdapterModule, QuotaWindow, ProviderQuotaResult, diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 54e456a295f..3d87f7a284c 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -125,6 +125,7 @@ export interface AdapterExecutionContext { runtime: AdapterRuntime; config: Record; context: Record; + runtimeCommandSpec?: AdapterRuntimeCommandSpec | null; executionTarget?: AdapterExecutionTarget | null; /** * Legacy remote transport view. Prefer `executionTarget`, which is the @@ -328,6 +329,23 @@ export interface AdapterConfigSchema { fields: ConfigFieldSchema[]; } +export interface AdapterRuntimeCommandSpec { + /** + * The command Paperclip should execute for this adapter in the current config. + */ + command: string; + /** + * Optional command name/path to probe for availability before launch. + * Defaults to `command` when omitted by the consumer. + */ + detectCommand?: string | null; + /** + * Optional shell snippet that can install or expose the adapter command in a + * fresh remote runtime. It should be idempotent. + */ + installCommand?: string | null; +} + export interface ServerAdapterModule { type: string; execute(ctx: AdapterExecutionContext): Promise; @@ -406,6 +424,11 @@ export interface ServerAdapterModule { * rather than reading config.paperclipRuntimeSkills. */ requiresMaterializedRuntimeSkills?: boolean; + /** + * Optional: describe how this adapter's runtime command should be launched + * and provisioned in fresh remote environments such as sandboxes. + */ + getRuntimeCommandSpec?: (config: Record) => AdapterRuntimeCommandSpec | null; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 7c77b66b6c0..cd3ed45c196 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, resolveAdapterExecutionTargetCommandForLogs, @@ -61,8 +62,10 @@ interface ClaudeExecutionInput { agent: AdapterExecutionContext["agent"]; config: Record; context: Record; + runtimeCommandSpec?: AdapterExecutionContext["runtimeCommandSpec"]; executionTarget?: ReturnType; authToken?: string; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; } interface ClaudeRuntimeConfig { @@ -112,7 +115,8 @@ function resolveClaudeBillingType(env: Record): "api" | "subscri } async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { - const { runId, agent, config, context, executionTarget, authToken } = input; + const { runId, agent, config, context, runtimeCommandSpec, executionTarget, authToken } = input; + const onLog = input.onLog ?? (async () => {}); const command = asString(config.command, "claude"); const workspaceContext = parseObject(context.paperclipWorkspace); @@ -239,7 +243,24 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise typeof entry[1] === "string", + ), + ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: runtimeCommandSpec?.installCommand, + detectCommand: runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); const loggedEnv = buildInvocationEnvForLogs(env, { @@ -248,8 +269,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; @@ -335,8 +354,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec: asNumber(config.timeoutSec, 0), + graceSec: asNumber(config.graceSec, 20), + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); const loggedEnv = buildInvocationEnvForLogs(env, { diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 2f521128f95..62147e6c392 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, @@ -307,6 +308,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", + ), + ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); let loggedEnv = buildInvocationEnvForLogs(env, { @@ -282,8 +300,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 015cd0c7ed1..a574fba839a 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { adapterExecutionTargetUsesPaperclipBridge, describeAdapterExecutionTarget, ensureAdapterExecutionTargetCommandResolvable, + ensureAdapterExecutionTargetRuntimeCommandInstalled, prepareAdapterExecutionTargetRuntime, readAdapterExecutionTarget, readAdapterExecutionTargetHomeDir, @@ -302,6 +303,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); let loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, { @@ -309,9 +323,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", ), ); + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + await ensureAdapterExecutionTargetRuntimeCommandInstalled({ + runId, + target: executionTarget, + installCommand: ctx.runtimeCommandSpec?.installCommand, + detectCommand: ctx.runtimeCommandSpec?.detectCommand, + cwd, + env: runtimeEnv, + timeoutSec, + graceSec, + onLog, + }); await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv); const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv); let loggedEnv = buildInvocationEnvForLogs(env, { @@ -364,8 +378,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; diff --git a/server/src/__tests__/environment-run-orchestrator.test.ts b/server/src/__tests__/environment-run-orchestrator.test.ts index 4817c448904..c4dbb58b7e0 100644 --- a/server/src/__tests__/environment-run-orchestrator.test.ts +++ b/server/src/__tests__/environment-run-orchestrator.test.ts @@ -420,6 +420,85 @@ describe("environmentRunOrchestrator — realizeForRun", () => { })); }); + it("runs project-level provision commands for ssh environments", async () => { + mockBuildWorkspaceRealizationRequest.mockReturnValue({ + version: 1, + adapterType: "gemini_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 @google/gemini-cli", + }, + }); + 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"), + lease: makeLease({ + provider: "ssh", + metadata: { + driver: "ssh", + remoteCwd: "/remote/workspace", + remoteWorkspacePath: "/remote/workspace", + host: "ssh.example.test", + port: 22, + username: "ssh-user", + }, + }), + })); + + expect(runtime.execute).toHaveBeenCalledWith(expect.objectContaining({ + command: "bash", + args: ["-lc", "npm install -g @google/gemini-cli"], + })); + expect(mockResolveEnvironmentExecutionTarget).toHaveBeenCalledOnce(); + }); + it("surfaces remote provision command failures before resolving the adapter target", async () => { mockBuildWorkspaceRealizationRequest.mockReturnValue({ version: 1, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 359aecd4bd1..ae356365659 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -1,4 +1,4 @@ -import type { AdapterModelProfileDefinition, ServerAdapterModule } from "./types.js"; +import type { AdapterModelProfileDefinition, AdapterRuntimeCommandSpec, ServerAdapterModule } from "./types.js"; import { getAdapterSessionManagement } from "@paperclipai/adapter-utils"; import { execute as acpxExecute, @@ -113,6 +113,44 @@ import { getDisabledAdapterTypes } from "../services/adapter-plugin-store.js"; import { processAdapter } from "./process/index.js"; import { httpAdapter } from "./http/index.js"; +function readConfiguredCommand(config: Record, fallback: string): string { + const value = typeof config.command === "string" ? config.command.trim() : ""; + return value.length > 0 ? value : fallback; +} + +function hasPathSeparator(command: string): boolean { + return command.includes("/") || command.includes("\\"); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function buildNpmRuntimeCommandSpec( + config: Record, + fallbackCommand: string, + packageName: string, +): AdapterRuntimeCommandSpec { + const command = readConfiguredCommand(config, fallbackCommand); + const canSelfInstall = !hasPathSeparator(command) && command === fallbackCommand; + return { + command, + detectCommand: command, + installCommand: canSelfInstall + ? `if ! command -v ${shellQuote(command)} >/dev/null 2>&1; then npm install -g ${shellQuote(packageName)}; fi` + : null, + }; +} + +function buildCursorRuntimeCommandSpec(config: Record): AdapterRuntimeCommandSpec { + const command = readConfiguredCommand(config, "agent"); + return { + command, + detectCommand: command, + installCommand: null, + }; +} + function normalizeHermesConfig(ctx: T): T { const config = ctx && typeof ctx === "object" && "config" in ctx && ctx.config && typeof ctx.config === "object" @@ -159,6 +197,8 @@ const claudeLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: false, + getRuntimeCommandSpec: (config) => + buildNpmRuntimeCommandSpec(config, "claude", "@anthropic-ai/claude-code"), agentConfigurationDoc: claudeAgentConfigurationDoc, getQuotaWindows: claudeGetQuotaWindows, }; @@ -195,6 +235,7 @@ const codexLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: false, + getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "codex", "@openai/codex"), agentConfigurationDoc: codexAgentConfigurationDoc, getQuotaWindows: codexGetQuotaWindows, }; @@ -214,6 +255,7 @@ const cursorLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: buildCursorRuntimeCommandSpec, agentConfigurationDoc: cursorAgentConfigurationDoc, }; @@ -231,6 +273,8 @@ const geminiLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => + buildNpmRuntimeCommandSpec(config, "gemini", "@google/gemini-cli"), agentConfigurationDoc: geminiAgentConfigurationDoc, }; @@ -260,6 +304,7 @@ const openCodeLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => buildNpmRuntimeCommandSpec(config, "opencode", "opencode-ai"), agentConfigurationDoc: openCodeAgentConfigurationDoc, }; @@ -278,6 +323,8 @@ const piLocalAdapter: ServerAdapterModule = { supportsInstructionsBundle: true, instructionsPathKey: "instructionsFilePath", requiresMaterializedRuntimeSkills: true, + getRuntimeCommandSpec: (config) => + buildNpmRuntimeCommandSpec(config, "pi", "@mariozechner/pi-coding-agent"), agentConfigurationDoc: piAgentConfigurationDoc, }; diff --git a/server/src/adapters/types.ts b/server/src/adapters/types.ts index 0b728b9c3a6..a113aeff2fe 100644 --- a/server/src/adapters/types.ts +++ b/server/src/adapters/types.ts @@ -30,5 +30,6 @@ export type { ConfigFieldOption, ConfigFieldSchema, AdapterConfigSchema, + AdapterRuntimeCommandSpec, ServerAdapterModule, } from "@paperclipai/adapter-utils"; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 71032747215..49a3a8e2d9d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -6984,6 +6984,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) runtime: runtimeForAdapter, config: runtimeConfig, context, + runtimeCommandSpec: adapter.getRuntimeCommandSpec?.(runtimeConfig) ?? null, executionTarget, executionTransport: remoteExecution ? { remoteExecution: remoteExecution as unknown as Record } From 6c090f84a9c99d77a39aa85b31b7d3d8f1711cf2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 18:36:13 -0700 Subject: [PATCH 11/27] Strip inherited host shell env from SSH remote execution (#5142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Agents executing on remote SSH hosts receive an env map built from the host > process's env plus per-run additions like `PAPERCLIP_API_KEY`, > `PAPERCLIP_RUN_ID`, etc. > - The env map currently includes inherited host vars by default, including > identity-bound ones like `PATH`, `HOME`, `USER`, `NVM_DIR`, `XDG_*` — > variables whose values are meaningful only on the host they came from > - Sending the host's `PATH` (containing host-only directories like a local > nvm install path) to a remote SSH box overrides the remote's actual `PATH` > and breaks command resolution. Same hazard for `HOME` (commands looking for > config files end up in a non-existent dir), `USER` (writes go to the wrong > path), etc. > - This PR adds `sanitizeSshRemoteEnv()` that drops inherited identity-bound > vars when their value matches the host process's value. Explicitly-set > values pass through untouched, so callers that genuinely want to override > remote `PATH` etc. still can — but accidental leakage from > `process.env` is filtered. > - The benefit is that SSH remote execution stops corrupting the remote > shell's environment with host-shaped paths, so commands resolve correctly > against the remote PATH and config files land in the remote `HOME` ## What Changed - New `sanitizeSshRemoteEnv(env, inheritedEnv = process.env)` in `packages/adapter-utils/src/server-utils.ts`. The identity-bound key set is: - `PATH`, `HOME`, `PWD`, `SHELL`, `USER`, `LOGNAME` - `NVM_DIR`, `TMPDIR`, `TMP`, `TEMP` - `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_RUNTIME_DIR` For any key in this set, the entry is dropped iff the env value equals the inherited (host process) value. Other keys pass through unchanged. - `readEnvValueCaseInsensitive(...)` helper handles Windows-style case-insensitive env var lookups. - Wired into `resolveSpawnTarget(...)` for the SSH transport. Sandbox and local paths are unaffected. - Tests added in `server-utils.test.ts` (~50 lines) covering: matching keys filtered, mismatched keys preserved, non-identity keys passed through, case insensitivity. ## Verification - `pnpm --filter @paperclipai/adapter-utils test -- server-utils` - Manual QA: run any adapter against an SSH-backed environment, confirm remote command resolution works (e.g. `node`, `npm`, the adapter's CLI) and config files land in the remote user's `HOME`. Compare to the prior behaviour by transiently re-introducing the inherited `PATH` and watching commands fail with `command not found`. ## Risks - Behavioural shift: SSH remote execution previously passed inherited host env vars verbatim. Code that relied on that (e.g. a remote command somehow expecting the host's `PATH`) will see different behaviour. None of the adapter code in this repo has such a dependency. - Edge case: if a caller explicitly sets `PATH` to the same value as the host's `PATH` (literally — same exact string), the sanitizer drops it as a leak. In practice no caller constructs the env this way. - Windows host: case-insensitive lookup handles `Path` vs `PATH` correctly. Tested. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../adapter-utils/src/server-utils.test.ts | 81 +++++++++++++++++++ packages/adapter-utils/src/server-utils.ts | 54 ++++++++++++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 1b74aefe090..9de5e06c0cf 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -12,6 +12,7 @@ import { renderPaperclipWakePrompt, runningProcesses, runChildProcess, + sanitizeSshRemoteEnv, shapePaperclipWorkspaceEnvForExecution, stringifyPaperclipWakePayload, } from "./server-utils.js"; @@ -61,6 +62,86 @@ describe("buildInvocationEnvForLogs", () => { }); }); +describe("sanitizeSshRemoteEnv", () => { + it("drops inherited host shell identity variables for SSH remote execution", () => { + expect( + sanitizeSshRemoteEnv( + { + PATH: "/host/bin:/usr/bin", + HOME: "/Users/local", + NVM_DIR: "/Users/local/.nvm", + TMPDIR: "/var/folders/local/T", + XDG_CONFIG_HOME: "/Users/local/.config", + SAFE_VALUE: "visible", + }, + { + PATH: "/host/bin:/usr/bin", + HOME: "/Users/local", + NVM_DIR: "/Users/local/.nvm", + TMPDIR: "/var/folders/local/T", + XDG_CONFIG_HOME: "/Users/local/.config", + }, + ), + ).toEqual({ + SAFE_VALUE: "visible", + }); + }); + + it("preserves explicit remote overrides even for filtered key names", () => { + expect( + sanitizeSshRemoteEnv( + { + PATH: "/custom/remote/bin:/usr/bin", + HOME: "/home/agent", + TMPDIR: "/tmp", + SAFE_VALUE: "visible", + }, + { + PATH: "/host/bin:/usr/bin", + HOME: "/Users/local", + TMPDIR: "/var/folders/local/T", + }, + ), + ).toEqual({ + PATH: "/custom/remote/bin:/usr/bin", + HOME: "/home/agent", + TMPDIR: "/tmp", + SAFE_VALUE: "visible", + }); + }); + + it("filters identity keys via case-insensitive match against the inherited env", () => { + expect( + sanitizeSshRemoteEnv( + { + // Caller passed PATH in upper case while the inherited (Windows-style) + // host env exposes it as Path. The lookup must still treat them as + // equal so the leaked host PATH gets stripped. + PATH: "/host/bin:/usr/bin", + HOME: "/host/home", + }, + { + Path: "/host/bin:/usr/bin", + home: "/host/home", + }, + ), + ).toEqual({}); + }); + + it("preserves explicitly-set identity keys when the inherited env disagrees in case but not in value", () => { + expect( + sanitizeSshRemoteEnv( + { + PATH: "/explicit/remote/bin", + }, + { + Path: "/host/bin:/usr/bin", + }, + ), + ).toEqual({ PATH: "/explicit/remote/bin" }); + }); +}); + describe("materializePaperclipSkillCopy", () => { it("refuses to materialize into an ancestor of the source", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-copy-")); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index cc6e4566c4d..1a07f643f8a 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -1039,6 +1039,56 @@ function quoteForCmd(arg: string) { return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped; } +const SSH_REMOTE_ENV_IDENTITY_KEYS = new Set([ + "PATH", + "HOME", + "PWD", + "SHELL", + "USER", + "LOGNAME", + "NVM_DIR", + "TMPDIR", + "TMP", + "TEMP", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_STATE_HOME", + "XDG_RUNTIME_DIR", +]); + +function readEnvValueCaseInsensitive(env: NodeJS.ProcessEnv, key: string): string | undefined { + const direct = env[key]; + if (typeof direct === "string") return direct; + const upper = key.toUpperCase(); + for (const [candidateKey, candidateValue] of Object.entries(env)) { + if (candidateKey.toUpperCase() === upper && typeof candidateValue === "string") { + return candidateValue; + } + } + return undefined; +} + +export function sanitizeSshRemoteEnv( + env: Record, + inheritedEnv: NodeJS.ProcessEnv = process.env, +): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(env)) { + const normalizedKey = key.toUpperCase(); + if (!SSH_REMOTE_ENV_IDENTITY_KEYS.has(normalizedKey)) { + sanitized[key] = value; + continue; + } + const inheritedValue = readEnvValueCaseInsensitive(inheritedEnv, key); + if (typeof inheritedValue === "string" && inheritedValue === value) { + continue; + } + sanitized[key] = value; + } + return sanitized; +} + function resolveWindowsCmdShell(env: NodeJS.ProcessEnv): string { const fallbackRoot = env.SystemRoot || process.env.SystemRoot || "C:\\Windows"; return path.join(fallbackRoot, "System32", "cmd.exe"); @@ -1064,9 +1114,9 @@ async function resolveSpawnTarget( spec: remote, command, args, - env: Object.fromEntries( + env: sanitizeSshRemoteEnv(Object.fromEntries( Object.entries(options.remoteEnv ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string"), - ), + )), }); return { command: sshResolved, From a5430f010d16e249e4e24334a891f19163df942e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 18:36:50 -0700 Subject: [PATCH 12/27] Handle Gemini assistant message events in JSONL parser (#5143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, including agents > running the Gemini CLI (`gemini-local` adapter) > - The Gemini CLI emits a JSONL event stream during a run that the adapter > parses to extract the assistant's response text, tool results, and usage > - Recent versions of the Gemini CLI emit assistant responses as > `{ "type": "message", "role": "assistant", "content": ... }` events in > addition to the previously-handled event shapes > - The parser was not handling the new event type, so the assistant's actual > response text was being silently dropped from parsed output. Callers ended > up with empty assistant messages even when Gemini had successfully > responded > - This PR teaches the parser to recognize `{type: "message", role: > "assistant"}` events and extract their content text via the same > `collectMessageText` helper used for other message-shaped events > - The benefit is that Gemini runs surface the assistant's real response in > downstream consumers (issue comments, run logs, downstream agent context) > instead of vanishing ## What Changed - `packages/adapters/gemini-local/src/server/parse.ts`: in `parseGeminiJsonl(...)`, add a branch for `event.type === "message"` with `role === "assistant"` that calls `messages.push(...collectMessageText(event.content))`. - `packages/adapters/gemini-local/src/server/parse.test.ts`: ~19 lines of coverage for the new branch. ## Verification - `pnpm --filter @paperclipai/adapter-gemini-local test -- parse` - Manual QA: run a Gemini agent on an issue, confirm the assistant's response appears as the issue comment / run output. Before this fix the comment was empty even when the run completed successfully. ## Risks - Tightly scoped: 8 lines of production code in one parser branch. No effect on existing event shapes or other adapters. - If the Gemini CLI changes its event schema again, this branch may need to be revisited — but adding it is strictly additive over current behaviour. ## Model Used - OpenAI GPT-5.4 (reasoning effort: high) via Codex CLI - Provider: OpenAI - Used to author the code changes in this PR ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots — N/A - [ ] I have updated relevant documentation to reflect my changes — N/A - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .../gemini-local/src/server/parse.test.ts | 46 +++++++++++++++++++ .../adapters/gemini-local/src/server/parse.ts | 13 ++++++ 2 files changed, 59 insertions(+) create mode 100644 packages/adapters/gemini-local/src/server/parse.test.ts diff --git a/packages/adapters/gemini-local/src/server/parse.test.ts b/packages/adapters/gemini-local/src/server/parse.test.ts new file mode 100644 index 00000000000..e50f779511a --- /dev/null +++ b/packages/adapters/gemini-local/src/server/parse.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { parseGeminiJsonl } from "./parse.js"; + +describe("parseGeminiJsonl", () => { + it("collects assistant text from message events with string content", () => { + const stdout = [ + '{"type":"init","session_id":"session-1"}', + '{"type":"message","role":"user","content":"Respond with hello."}', + '{"type":"message","role":"assistant","content":"hello","delta":true}', + '{"type":"result","status":"success"}', + ].join("\n"); + + const parsed = parseGeminiJsonl(stdout); + + expect(parsed.sessionId).toBe("session-1"); + expect(parsed.summary).toBe("hello"); + expect(parsed.errorMessage).toBeNull(); + }); + + it("collects assistant text from message events with structured object content", () => { + const stdout = [ + '{"type":"init","session_id":"session-2"}', + '{"type":"message","role":"assistant","content":{"content":[{"type":"text","text":"first part"},{"type":"text","text":"second part"}]}}', + '{"type":"result","status":"success"}', + ].join("\n"); + + const parsed = parseGeminiJsonl(stdout); + + expect(parsed.sessionId).toBe("session-2"); + expect(parsed.summary).toBe("first part\n\nsecond part"); + expect(parsed.errorMessage).toBeNull(); + }); + + it("ignores non-assistant message events", () => { + const stdout = [ + '{"type":"message","role":"user","content":"hidden user input"}', + '{"type":"message","role":"system","content":"hidden system note"}', + '{"type":"message","role":"assistant","content":"visible response"}', + '{"type":"result","status":"success"}', + ].join("\n"); + + const parsed = parseGeminiJsonl(stdout); + + expect(parsed.summary).toBe("visible response"); + }); +}); diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts index a9d3df9eee1..423e4db4a72 100644 --- a/packages/adapters/gemini-local/src/server/parse.ts +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -121,6 +121,19 @@ export function parseGeminiJsonl(stdout: string) { continue; } + if (type === "message") { + const role = asString(event.role, "").trim().toLowerCase(); + if (role === "assistant") { + // Mirror the assistant-event handling above: collect every assistant + // message including deltas. Gemini CLI emits these as discrete final + // messages (one per assistant turn), not as cumulative streaming + // tokens, so collecting all of them produces the expected concatenated + // turn-by-turn summary rather than duplicated text. + messages.push(...collectMessageText(event.content)); + } + continue; + } + if (type === "result") { resultEvent = event; accumulateUsage(usage, event.usage ?? event.usageMetadata); From 29401b231bd403e49d207eac45109bd683e5962e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 19:31:28 -0700 Subject: [PATCH 13/27] fix(ci): gate new release packages on npm bootstrap (#5146) ## Thinking Path > - Paperclip is a control plane for autonomous agent companies, so its release automation is part of the core operator trust boundary. > - The affected subsystem is npm/GitHub Actions release publishing for the public monorepo packages. > - The concrete failure was that a newly added package reached `master`, the canary workflow attempted its first publish, and npm trusted publishing was not yet bootstrapped for that package. > - That means the problem is not just one broken run; it is a missing pre-merge guard that lets release-ineligible packages land and only fail once `publish_canary` runs. > - This pull request makes release enrollment explicit, validates that enrollment in CI, and adds a PR-time bootstrap check against npm for changed release-enabled package manifests. > - The result is that we keep trusted publishing, avoid teaching CI to `npm adduser`, and move this class of failure from post-merge canary time to pre-merge review time. ## What Changed - Added `scripts/release-package-manifest.json` so release-managed public packages are explicitly enrolled instead of being inferred from every non-private workspace package. - Hardened `scripts/release-package-map.mjs` to validate the manifest before release workflows rewrite versions or assemble publish payloads. - Added `scripts/check-release-package-bootstrap.mjs` and wired it into `.github/workflows/pr.yml` so PRs that change a release-enabled package manifest fail if that package does not already exist on npm. - Added release-package manifest coverage tests to `scripts/release-package-map.test.mjs` and included them in `pnpm run test:release-registry`. - Wired manifest validation into `.github/workflows/release.yml` and documented the first-publish bootstrap policy in `doc/PUBLISHING.md` and `doc/RELEASE-AUTOMATION-SETUP.md`. ## Verification - `pnpm run test:release-registry` - `./scripts/release.sh canary --skip-verify --dry-run` - Confirmed the committed diff contains no obvious PII/secrets via targeted pattern scan before pushing. ## Risks - Low risk overall: this is CI/release-policy code, not product runtime logic. - The new PR bootstrap check depends on npm metadata availability, so a transient npm outage could block a PR that changes a release-enabled package manifest. - The manifest introduces a new source of truth that must stay aligned with public package additions, but that is intentional and now enforced. ## Model Used - OpenAI Codex via the `codex_local` Paperclip adapter; GPT-5-based coding agent with tool use, terminal execution, git, and GitHub CLI. Exact served model ID/context window are not exposed by the local runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- .github/workflows/pr.yml | 9 + .github/workflows/release.yml | 12 + doc/PUBLISHING.md | 52 ++++ doc/RELEASE-AUTOMATION-SETUP.md | 21 ++ package.json | 3 +- scripts/bootstrap-npm-package.mjs | 294 ++++++++++++++++++ scripts/bootstrap-npm-package.test.mjs | 54 ++++ scripts/check-release-package-bootstrap.mjs | 206 ++++++++++++ .../check-release-package-bootstrap.test.mjs | 104 +++++++ scripts/release-package-manifest.json | 92 ++++++ scripts/release-package-map.mjs | 146 ++++++++- scripts/release-package-map.test.mjs | 24 ++ .../heartbeat-plugin-environment.test.ts | 2 + 13 files changed, 1002 insertions(+), 17 deletions(-) create mode 100644 scripts/bootstrap-npm-package.mjs create mode 100644 scripts/bootstrap-npm-package.test.mjs create mode 100644 scripts/check-release-package-bootstrap.mjs create mode 100644 scripts/check-release-package-bootstrap.test.mjs create mode 100644 scripts/release-package-manifest.json create mode 100644 scripts/release-package-map.test.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index ad802136472..8ac8f710ad5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -45,6 +45,15 @@ jobs: - name: Validate Dockerfile deps stage run: node ./scripts/check-docker-deps-stage.mjs + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + + - name: Verify release package bootstrap for changed manifests + run: | + mapfile -t changed_paths < <(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}") + PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA="${{ github.event.pull_request.base.sha }}" \ + node ./scripts/check-release-package-bootstrap.mjs "${changed_paths[@]}" + - name: Validate dependency resolution when manifests change run: | changed="$(git diff --name-only "${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}")" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b5983ccd1f..c16bad2323d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -89,6 +92,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -139,6 +145,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile @@ -177,6 +186,9 @@ jobs: node-version: 24 cache: pnpm + - name: Validate release package manifest + run: node ./scripts/release-package-map.mjs check + - name: Install dependencies run: pnpm install --no-frozen-lockfile diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 784a12fd6a2..c430d3473a8 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -176,6 +176,58 @@ That means: See [doc/RELEASE-AUTOMATION-SETUP.md](RELEASE-AUTOMATION-SETUP.md) for the GitHub/npm setup steps. +## Release enrollment for new public packages + +Paperclip does not auto-publish every non-private workspace package anymore. +CI publishing is controlled by [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json). + +When you add a new public package: + +1. add it to the manifest and decide whether CI should publish it immediately +2. if CI should publish it, bootstrap the package on npm before merge +3. if CI should not publish it yet, keep `"publishFromCi": false` +4. only enable `"publishFromCi": true` after npm trusted publishing is configured for that package + +PR CI now checks changed release-enabled package manifests against npm. That catches a missing first-publish bootstrap before the change reaches `master`. + +### One-time bootstrap sequence for a new package + +The first publish of a brand-new package still needs one human maintainer with npm write access. +After that, trusted publishing can take over. + +Example for `@paperclipai/adapter-acpx-local` from the repo root: + +```bash +# safe preview +pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local + +# one-time first publish from an authenticated maintainer machine +pnpm run release:bootstrap-package -- @paperclipai/adapter-acpx-local --publish --otp 123456 +``` + +The helper script: + +- checks that the package does not already exist on npm +- builds the target package unless `--skip-build` is passed +- runs `npm pack --dry-run` in the package directory +- only runs the real `npm publish --access public` when `--publish --otp ` is provided + +For the real `--publish` step, the maintainer machine must already be authenticated to npm. +If `npm whoami` returns `401`, first run `npm logout --registry=https://registry.npmjs.org/` to clear any stale local auth, then run `npm login` or `npm adduser` locally as an npm org member, and finally rerun the helper. +That local human auth is fine for the one-time bootstrap publish; we just do not want the same auth model inside CI. +The helper now requires `--otp ` up front for `--publish`, so it fails before the real publish attempt if the one-time password is missing. + +After that first publish succeeds: + +1. open `https://www.npmjs.com/package/@paperclipai/adapter-acpx-local` +2. go to `Settings` → `Trusted publishing` +3. add repository `paperclipai/paperclip` +4. set workflow filename to `release.yml` +5. optionally go to `Settings` → `Publishing access` and enable `Require two-factor authentication and disallow tokens` +6. keep `publishFromCi: true` in [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json) + +Once those steps are done, future canary and stable publishes for that package are automated through GitHub OIDC. The manual step is only the first package creation on npm. + ## Rollback model Rollback does not unpublish anything. diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index 25982892d35..d6e08b9f1a5 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -67,6 +67,27 @@ Why: - the single `release.yml` workflow handles both canary and stable publishing - GitHub environments `npm-canary` and `npm-stable` still enforce different approval rules on the GitHub side +### 2.2.1. Newly added public packages need a bootstrap phase + +Trusted publishing is configured on the npm package itself, not at the repo scope. +That means a brand-new public package must not be auto-enrolled into CI publishing until its npm package exists and its trusted publisher has been configured. + +Repo policy: + +1. add every non-private package to [`scripts/release-package-manifest.json`](../scripts/release-package-manifest.json) +2. set `"publishFromCi": true` only when CI is expected to publish that package +3. if the package is not ready for CI publishing yet, keep `"publishFromCi": false` +4. complete the package bootstrap before merging any PR that changes a release-enabled new package + +Bootstrap sequence for a new package: + +1. publish the package once from a trusted maintainer machine using normal npm auth +2. open that package on npm and add the `paperclipai/paperclip` trusted publisher for `.github/workflows/release.yml` +3. rerun or dry-run the release flow as needed to confirm CI publishing now works +4. only then enable `"publishFromCi": true` + +PR CI enforces this by checking changed release-enabled package manifests against npm. That keeps `master` canary publishing healthy while preserving the no-long-lived-token model for normal CI releases. + ### 2.3. Verify trusted publishing before removing old auth After the workflows are live: diff --git a/package.json b/package.json index 7a9ec0c27f4..9d6b5bec65e 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,14 @@ "release:stable": "./scripts/release.sh stable", "release:github": "./scripts/create-github-release.sh", "release:rollback": "./scripts/rollback-latest.sh", + "release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs", "check:tokens": "node scripts/check-forbidden-tokens.mjs", "docs:dev": "cd docs && npx mintlify dev", "smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh", "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs", - "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs", + "test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "test:e2e:multiuser-authenticated": "npx playwright test --config tests/e2e/playwright-multiuser-authenticated.config.ts", diff --git a/scripts/bootstrap-npm-package.mjs b/scripts/bootstrap-npm-package.mjs new file mode 100644 index 00000000000..9b1e01beb04 --- /dev/null +++ b/scripts/bootstrap-npm-package.mjs @@ -0,0 +1,294 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +import { buildReleasePackagePlan } from "./release-package-map.mjs"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); + +function normalizePath(filePath) { + return filePath.replace(/\\/g, "/").replace(/^\.\//, ""); +} + +function usage() { + process.stderr.write( + [ + "Usage:", + " node scripts/bootstrap-npm-package.mjs [--publish --otp ] [--skip-build]", + "", + "Examples:", + " node scripts/bootstrap-npm-package.mjs @paperclipai/adapter-acpx-local", + " node scripts/bootstrap-npm-package.mjs packages/adapters/acpx-local --publish", + "", + ].join("\n"), + ); +} + +function parseArgs(argv) { + const flags = new Set(); + let selector = null; + let otp = null; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + + if (arg === "--publish" || arg === "--skip-build") { + flags.add(arg); + continue; + } + + if (arg === "--otp") { + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error("expected a one-time password after --otp"); + } + otp = value; + index += 1; + continue; + } + + if (arg === "--help" || arg === "-h") { + return { help: true, selector: null, publish: false, skipBuild: false, otp: null }; + } + + if (arg.startsWith("--")) { + throw new Error(`unknown option: ${arg}`); + } + + if (selector) { + throw new Error("expected exactly one package selector"); + } + + selector = arg; + } + + return { + help: false, + selector, + publish: flags.has("--publish"), + skipBuild: flags.has("--skip-build"), + otp, + }; +} + +function runCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: "utf8", + stdio: ["inherit", "pipe", "pipe"], + ...options, + }); + + if (result.error) { + throw result.error; + } + + return result; +} + +function runChecked(command, args, options = {}) { + const result = runCommand(command, args, options); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed with status ${result.status ?? "unknown"}`); + } +} + +function formatCommand(command, args) { + return `${command} ${args.join(" ")}`; +} + +function ensureNpmAuth() { + const result = runCommand("npm", ["whoami"]); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + + if (result.status === 0) { + return; + } + + const output = `${stdout}\n${stderr}`.trim(); + if (/\bE401\b|401 Unauthorized/i.test(output)) { + throw new Error( + [ + "npm auth check failed.", + "This usually means the machine is either not logged into npm yet or has a stale token in ~/.npmrc.", + "Run `npm logout --registry=https://registry.npmjs.org/` and then `npm login` or `npm adduser` on this maintainer machine with an npm account that can publish to the @paperclipai scope, then rerun with --publish.", + "Do not use this auth flow in CI; it is only for the one-time human bootstrap publish.", + ].join(" "), + ); + } + + throw new Error("npm whoami failed"); +} + +function inspectNpmPackage(packageName) { + const result = runCommand("npm", ["view", packageName, "version", "--json"]); + + if (result.status === 0) { + const version = JSON.parse((result.stdout ?? "").trim()); + return { exists: true, version }; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + if (/\bE404\b|404 Not Found|could not be found/i.test(output)) { + return { exists: false }; + } + + process.stderr.write(output ? `${output}\n` : ""); + throw new Error(`failed to query npm for ${packageName}`); +} + +function resolveTargetPackage(selector, packages = buildReleasePackagePlan()) { + const normalizedSelector = normalizePath(selector); + const matches = packages.filter( + (pkg) => pkg.name === selector || normalizePath(pkg.dir) === normalizedSelector, + ); + + if (matches.length === 1) { + return matches[0]; + } + + if (matches.length > 1) { + throw new Error(`package selector is ambiguous: ${selector}`); + } + + throw new Error( + `unknown package selector: ${selector}\nKnown packages:\n- ${packages.map((pkg) => `${pkg.name} (${pkg.dir})`).join("\n- ")}`, + ); +} + +function printNextSteps(pkg) { + process.stdout.write( + [ + "", + "Publish succeeded. Next:", + `1. Open https://www.npmjs.com/package/${pkg.name}`, + "2. Go to Settings -> Trusted publishing", + "3. Add repository paperclipai/paperclip", + "4. Set workflow filename to release.yml", + "5. Optionally enable Settings -> Publishing access -> Require two-factor authentication and disallow tokens", + "", + ].join("\n"), + ); +} + +function publishPackage(pkg, otp) { + const publishArgs = ["publish", "--access", "public"]; + if (otp) { + publishArgs.push("--otp", otp); + } + + const result = runCommand("npm", publishArgs, { cwd: join(repoRoot, pkg.dir) }); + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + const output = `${stdout}\n${stderr}`.trim(); + + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + + if (result.status === 0) { + return; + } + + if (/\bEOTP\b|one-time password/i.test(output)) { + throw new Error( + [ + "npm publish reached the publish-time 2FA check.", + "Complete the browser auth URL printed by npm and rerun the helper, or rerun with `--otp ` if your npm account uses authenticator-app codes.", + ].join(" "), + ); + } + + throw new Error(`${formatCommand("npm", publishArgs)} failed with status ${result.status ?? "unknown"}`); +} + +function main(argv) { + const { help, selector, publish, skipBuild, otp } = parseArgs(argv); + + if (help) { + usage(); + return; + } + + if (!selector) { + usage(); + throw new Error("missing package selector"); + } + + const pkg = resolveTargetPackage(selector); + process.stdout.write(`Selected ${pkg.name} (${pkg.dir})\n`); + + if (publish && !otp) { + throw new Error("`--publish` requires `--otp `. Generate a fresh npm one-time password and rerun."); + } + + const npmState = inspectNpmPackage(pkg.name); + if (npmState.exists) { + throw new Error(`${pkg.name} already exists on npm at version ${npmState.version}; bootstrap is only for first publish`); + } + + process.stdout.write(`${pkg.name} is not on npm yet; continuing with bootstrap flow.\n`); + + if (publish) { + process.stdout.write("Checking npm auth with npm whoami...\n"); + ensureNpmAuth(); + } + + if (!skipBuild && typeof pkg.pkg?.scripts?.build === "string") { + process.stdout.write(`Building ${pkg.name}...\n`); + runChecked("pnpm", ["--filter", pkg.name, "build"]); + } + + process.stdout.write(`Previewing publish payload for ${pkg.name}...\n`); + runChecked("npm", ["pack", "--dry-run"], { cwd: join(repoRoot, pkg.dir) }); + + if (!publish) { + process.stdout.write( + [ + "", + "Dry run complete. To perform the first publish from an authenticated maintainer machine, run:", + `node scripts/bootstrap-npm-package.mjs ${pkg.name} --publish --otp `, + "", + ].join("\n"), + ); + return; + } + + process.stdout.write(`Publishing ${pkg.name}...\n`); + publishPackage(pkg, otp); + printNextSteps(pkg); +} + +const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isDirectRun) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); + } +} + +export { + ensureNpmAuth, + inspectNpmPackage, + parseArgs, + publishPackage, + resolveTargetPackage, +}; diff --git a/scripts/bootstrap-npm-package.test.mjs b/scripts/bootstrap-npm-package.test.mjs new file mode 100644 index 00000000000..48deb739583 --- /dev/null +++ b/scripts/bootstrap-npm-package.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseArgs, resolveTargetPackage } from "./bootstrap-npm-package.mjs"; + +test("parseArgs recognizes publish and skip-build flags", () => { + assert.deepEqual(parseArgs(["@paperclipai/adapter-acpx-local", "--publish", "--skip-build"]), { + help: false, + selector: "@paperclipai/adapter-acpx-local", + publish: true, + skipBuild: true, + otp: null, + }); +}); + +test("parseArgs accepts an explicit otp value", () => { + assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish", "--otp", "123456"]), { + help: false, + selector: "packages/adapters/acpx-local", + publish: true, + skipBuild: false, + otp: "123456", + }); +}); + +test("parseArgs leaves otp null when omitted", () => { + assert.deepEqual(parseArgs(["packages/adapters/acpx-local", "--publish"]), { + help: false, + selector: "packages/adapters/acpx-local", + publish: true, + skipBuild: false, + otp: null, + }); +}); + +test("parseArgs returns help mode", () => { + assert.deepEqual(parseArgs(["--help"]), { + help: true, + selector: null, + publish: false, + skipBuild: false, + otp: null, + }); +}); + +test("resolveTargetPackage matches by package name or dir", () => { + const packages = [ + { dir: "packages/a", name: "@paperclipai/a", pkg: {} }, + { dir: "packages/b", name: "@paperclipai/b", pkg: {} }, + ]; + + assert.equal(resolveTargetPackage("@paperclipai/a", packages).dir, "packages/a"); + assert.equal(resolveTargetPackage("./packages/b", packages).name, "@paperclipai/b"); +}); diff --git a/scripts/check-release-package-bootstrap.mjs b/scripts/check-release-package-bootstrap.mjs new file mode 100644 index 00000000000..a9d792df807 --- /dev/null +++ b/scripts/check-release-package-bootstrap.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +import { buildReleasePackagePlan } from "./release-package-map.mjs"; + +function normalizePath(filePath) { + return filePath.replace(/\\/g, "/"); +} + +function classifyNpmViewFailure(output) { + return /\bE404\b|404 Not Found|could not be found/i.test(output) ? "missing" : "registry_error"; +} + +function inspectNpmPackage(packageName) { + const result = spawnSync("npm", ["view", packageName, "name", "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status === 0) { + return { status: "exists" }; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + const failureType = classifyNpmViewFailure(output); + + if (failureType === "missing") { + return { status: "missing" }; + } + + return { + status: "registry_error", + detail: output || `npm view exited with status ${result.status ?? "unknown"}`, + }; +} + +function readGitFileAtRevision(revision, filePath) { + const result = spawnSync("git", ["show", `${revision}:${normalizePath(filePath)}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + + if (result.status === 0) { + return result.stdout; + } + + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); + + if ( + /exists on disk, but not in/i.test(output) || + /does not exist in/i.test(output) + ) { + return null; + } + + throw new Error(`failed to read ${filePath} at ${revision}:\n${output || "git show failed"}`); +} + +function getBaseReleaseState( + revision, + releasePackages = buildReleasePackagePlan(), + readFileAtRevision = readGitFileAtRevision, +) { + if (!revision) return null; + + const manifestText = readFileAtRevision(revision, "scripts/release-package-manifest.json"); + + if (manifestText) { + const manifestEntries = JSON.parse(manifestText); + + if (!Array.isArray(manifestEntries)) { + throw new Error(`expected scripts/release-package-manifest.json at ${revision} to contain an array`); + } + + return { + source: "manifest", + byDir: new Map( + manifestEntries + .filter((entry) => entry?.publishFromCi === true && typeof entry.dir === "string" && typeof entry.name === "string") + .map((entry) => [entry.dir, { name: entry.name, publishFromCi: true }]), + ), + }; + } + + const byDir = new Map(); + + for (const pkg of releasePackages) { + const packageJsonText = readFileAtRevision(revision, `${pkg.dir}/package.json`); + if (!packageJsonText) continue; + + const basePackage = JSON.parse(packageJsonText); + if (basePackage.private) continue; + + byDir.set(pkg.dir, { + name: basePackage.name, + publishFromCi: true, + }); + } + + return { + source: "public-packages", + byDir, + }; +} + +function collectReleasePackagesForChangedPaths( + changedPaths, + releasePackages = buildReleasePackagePlan(), + baseReleaseState = null, +) { + const normalizedChangedPaths = changedPaths.map(normalizePath); + const manifestFileChanged = normalizedChangedPaths.includes("scripts/release-package-manifest.json"); + const changedReleasePackages = []; + const seen = new Set(); + + for (const pkg of releasePackages) { + if (!pkg.publishFromCi) continue; + const packageJsonPath = `${pkg.dir}/package.json`; + const packageJsonChanged = normalizedChangedPaths.includes(packageJsonPath); + const basePackage = baseReleaseState?.byDir.get(pkg.dir); + const newlyReleaseEnabled = + manifestFileChanged && + (!baseReleaseState || !basePackage || basePackage.publishFromCi !== true || basePackage.name !== pkg.name); + const isRelevant = packageJsonChanged || newlyReleaseEnabled; + + if (!isRelevant) continue; + if (seen.has(pkg.name)) continue; + + changedReleasePackages.push(pkg); + seen.add(pkg.name); + } + + return changedReleasePackages; +} + +function main(changedPaths) { + const releasePackages = buildReleasePackagePlan(); + const baseReleaseState = getBaseReleaseState(process.env.PAPERCLIP_RELEASE_BOOTSTRAP_BASE_SHA, releasePackages); + const changedReleasePackages = collectReleasePackagesForChangedPaths(changedPaths, releasePackages, baseReleaseState); + + if (changedReleasePackages.length === 0) { + process.stdout.write("No release-enabled package manifests changed in this PR.\n"); + return; + } + + const missingPackages = []; + const registryFailures = []; + + for (const pkg of changedReleasePackages) { + const npmStatus = inspectNpmPackage(pkg.name); + + if (npmStatus.status === "missing") { + missingPackages.push(pkg); + continue; + } + + if (npmStatus.status === "registry_error") { + registryFailures.push({ pkg, detail: npmStatus.detail }); + } + } + + if (missingPackages.length > 0) { + const details = missingPackages + .map( + (pkg) => + `${pkg.name} (${pkg.dir}) is release-enabled but does not exist on npm yet; bootstrap the first publish before merge or keep it out of CI release enrollment`, + ) + .join("\n- "); + + throw new Error(`release package bootstrap check failed:\n- ${details}`); + } + + if (registryFailures.length > 0) { + const details = registryFailures + .map( + ({ pkg, detail }) => + `${pkg.name} (${pkg.dir}) could not be checked against npm due to a registry error:\n${detail}`, + ) + .join("\n- "); + + throw new Error(`release package bootstrap check could not verify npm state:\n- ${details}`); + } + + process.stdout.write( + `Release bootstrap OK for changed manifests: ${changedReleasePackages.map((pkg) => pkg.name).join(", ")}\n`, + ); +} + +if (process.argv[1] && normalizePath(process.argv[1]).endsWith("scripts/check-release-package-bootstrap.mjs")) { + main(process.argv.slice(2)); +} + +export { + classifyNpmViewFailure, + collectReleasePackagesForChangedPaths, + getBaseReleaseState, +}; diff --git a/scripts/check-release-package-bootstrap.test.mjs b/scripts/check-release-package-bootstrap.test.mjs new file mode 100644 index 00000000000..90c923089da --- /dev/null +++ b/scripts/check-release-package-bootstrap.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + classifyNpmViewFailure, + collectReleasePackagesForChangedPaths, + getBaseReleaseState, +} from "./check-release-package-bootstrap.mjs"; + +test("manifest changes without base state validate all release-enabled packages", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + { dir: "packages/c", name: "@paperclipai/c", publishFromCi: false }, + ]; + + const changedPackages = collectReleasePackagesForChangedPaths( + ["scripts/release-package-manifest.json"], + releasePackages, + ); + + assert.deepEqual( + changedPackages.map((pkg) => pkg.name), + ["@paperclipai/a", "@paperclipai/b"], + ); +}); + +test("manifest changes only validate newly release-enabled packages relative to base state", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + { dir: "packages/c", name: "@paperclipai/c", publishFromCi: false }, + ]; + const baseReleaseState = { + source: "manifest", + byDir: new Map([["packages/a", { name: "@paperclipai/a", publishFromCi: true }]]), + }; + + const changedPackages = collectReleasePackagesForChangedPaths( + ["scripts/release-package-manifest.json"], + releasePackages, + baseReleaseState, + ); + + assert.deepEqual( + changedPackages.map((pkg) => pkg.name), + ["@paperclipai/b"], + ); +}); + +test("package-specific changes only validate affected release-enabled packages", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + ]; + + const changedPackages = collectReleasePackagesForChangedPaths( + ["packages/b/package.json", "README.md"], + releasePackages, + ); + + assert.deepEqual( + changedPackages.map((pkg) => pkg.name), + ["@paperclipai/b"], + ); +}); + +test("npm E404 failures are treated as missing packages", () => { + assert.equal(classifyNpmViewFailure("npm error code E404"), "missing"); + assert.equal(classifyNpmViewFailure("404 Not Found"), "missing"); +}); + +test("non-404 npm failures are treated as registry errors", () => { + assert.equal(classifyNpmViewFailure("npm error code EAI_AGAIN"), "registry_error"); + assert.equal(classifyNpmViewFailure("npm error code E429"), "registry_error"); +}); + +test("base release state falls back to public packages when manifest is absent", () => { + const releasePackages = [ + { dir: "packages/a", name: "@paperclipai/a", publishFromCi: true }, + { dir: "packages/b", name: "@paperclipai/b", publishFromCi: true }, + ]; + + const baseReleaseState = getBaseReleaseState("base-sha", releasePackages, (_revision, filePath) => { + if (filePath === "scripts/release-package-manifest.json") { + return null; + } + + if (filePath === "packages/a/package.json") { + return JSON.stringify({ name: "@paperclipai/a", private: false }); + } + + if (filePath === "packages/b/package.json") { + return JSON.stringify({ name: "@paperclipai/b", private: true }); + } + + return null; + }); + + assert.equal(baseReleaseState?.source, "public-packages"); + assert.deepEqual([...baseReleaseState.byDir.entries()], [ + ["packages/a", { name: "@paperclipai/a", publishFromCi: true }], + ]); +}); diff --git a/scripts/release-package-manifest.json b/scripts/release-package-manifest.json new file mode 100644 index 00000000000..a81cf28f93b --- /dev/null +++ b/scripts/release-package-manifest.json @@ -0,0 +1,92 @@ +[ + { + "dir": "packages/adapter-utils", + "name": "@paperclipai/adapter-utils", + "publishFromCi": true + }, + { + "dir": "packages/adapters/acpx-local", + "name": "@paperclipai/adapter-acpx-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/claude-local", + "name": "@paperclipai/adapter-claude-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/codex-local", + "name": "@paperclipai/adapter-codex-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/cursor-local", + "name": "@paperclipai/adapter-cursor-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/gemini-local", + "name": "@paperclipai/adapter-gemini-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/opencode-local", + "name": "@paperclipai/adapter-opencode-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/pi-local", + "name": "@paperclipai/adapter-pi-local", + "publishFromCi": true + }, + { + "dir": "packages/adapters/openclaw-gateway", + "name": "@paperclipai/adapter-openclaw-gateway", + "publishFromCi": true + }, + { + "dir": "packages/shared", + "name": "@paperclipai/shared", + "publishFromCi": true + }, + { + "dir": "packages/db", + "name": "@paperclipai/db", + "publishFromCi": true + }, + { + "dir": "packages/plugins/sdk", + "name": "@paperclipai/plugin-sdk", + "publishFromCi": true + }, + { + "dir": "server", + "name": "@paperclipai/server", + "publishFromCi": true + }, + { + "dir": "cli", + "name": "paperclipai", + "publishFromCi": true + }, + { + "dir": "packages/mcp-server", + "name": "@paperclipai/mcp-server", + "publishFromCi": true + }, + { + "dir": "packages/plugins/create-paperclip-plugin", + "name": "@paperclipai/create-paperclip-plugin", + "publishFromCi": true + }, + { + "dir": "packages/plugins/sandbox-providers/e2b", + "name": "@paperclipai/plugin-e2b", + "publishFromCi": true + }, + { + "dir": "ui", + "name": "@paperclipai/ui", + "publishFromCi": true + } +] diff --git a/scripts/release-package-map.mjs b/scripts/release-package-map.mjs index 60deba31044..a990b88ef78 100644 --- a/scripts/release-package-map.mjs +++ b/scripts/release-package-map.mjs @@ -6,6 +6,7 @@ import { dirname, join, resolve } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); +const manifestPath = join(repoRoot, "scripts", "release-package-manifest.json"); const roots = ["packages", "server", "ui", "cli"]; function readJson(filePath) { @@ -48,6 +49,84 @@ function discoverPublicPackages() { return packages; } +function loadReleaseManifest() { + const manifest = readJson(manifestPath); + + if (!Array.isArray(manifest)) { + throw new Error(`expected ${manifestPath} to contain an array.`); + } + + return manifest.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`manifest entry ${index + 1} in ${manifestPath} must be an object.`); + } + + if (typeof entry.dir !== "string" || entry.dir.length === 0) { + throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "dir".`); + } + + if (typeof entry.name !== "string" || entry.name.length === 0) { + throw new Error(`manifest entry ${index + 1} in ${manifestPath} is missing a non-empty "name".`); + } + + if (typeof entry.publishFromCi !== "boolean") { + throw new Error( + `manifest entry ${index + 1} (${entry.dir}) in ${manifestPath} must set boolean "publishFromCi".`, + ); + } + + return entry; + }); +} + +function buildReleasePackagePlan() { + const discoveredPackages = discoverPublicPackages(); + const manifestEntries = loadReleaseManifest(); + const packageByDir = new Map(discoveredPackages.map((pkg) => [pkg.dir, pkg])); + const manifestByDir = new Map(); + const problems = []; + + for (const entry of manifestEntries) { + if (manifestByDir.has(entry.dir)) { + problems.push(`duplicate manifest entry for ${entry.dir}`); + continue; + } + + manifestByDir.set(entry.dir, entry); + const pkg = packageByDir.get(entry.dir); + + if (!pkg) { + problems.push(`${entry.dir} is listed in ${manifestPath} but is not a public package in this repo`); + continue; + } + + if (pkg.name !== entry.name) { + problems.push( + `${entry.dir} is listed as ${entry.name} in ${manifestPath}, but package.json declares ${pkg.name}`, + ); + } + } + + for (const pkg of discoveredPackages) { + if (!manifestByDir.has(pkg.dir)) { + problems.push( + `${pkg.dir} (${pkg.name}) is public but missing from ${manifestPath}; add it with publishFromCi true or false`, + ); + } + } + + if (problems.length > 0) { + throw new Error(`release package manifest validation failed:\n- ${problems.join("\n- ")}`); + } + + const packages = discoveredPackages.map((pkg) => ({ + ...pkg, + publishFromCi: manifestByDir.get(pkg.dir).publishFromCi, + })); + + return packages; +} + function sortTopologically(packages) { const byName = new Map(packages.map((pkg) => [pkg.name, pkg])); const visited = new Set(); @@ -57,7 +136,7 @@ function sortTopologically(packages) { function visit(pkg) { if (visited.has(pkg.name)) return; if (visiting.has(pkg.name)) { - throw new Error(`cycle detected in public package graph at ${pkg.name}`); + throw new Error(`cycle detected in release package graph at ${pkg.name}`); } visiting.add(pkg.name); @@ -87,6 +166,10 @@ function sortTopologically(packages) { return ordered; } +function getReleasePackages() { + return sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi)); +} + function replaceWorkspaceDeps(deps, version) { if (!deps) return deps; const next = { ...deps }; @@ -101,7 +184,7 @@ function replaceWorkspaceDeps(deps, version) { } function setVersion(version) { - const packages = sortTopologically(discoverPublicPackages()); + const packages = getReleasePackages(); for (const pkg of packages) { const nextPkg = { @@ -134,17 +217,32 @@ function setVersion(version) { } function listPackages() { - const packages = sortTopologically(discoverPublicPackages()); + const packages = getReleasePackages(); for (const pkg of packages) { process.stdout.write(`${pkg.dir}\t${pkg.name}\t${pkg.version}\n`); } } +function checkConfiguration() { + const packages = buildReleasePackagePlan(); + const enabledCount = packages.filter((pkg) => pkg.publishFromCi).length; + const disabledCount = packages.length - enabledCount; + + if (enabledCount === 0) { + throw new Error(`no packages are enabled for CI publishing in ${manifestPath}`); + } + + process.stdout.write( + `Release package manifest OK: ${enabledCount} enabled for CI publish, ${disabledCount} disabled pending bootstrap.\n`, + ); +} + function usage() { process.stderr.write( [ "Usage:", " node scripts/release-package-map.mjs list", + " node scripts/release-package-map.mjs check", " node scripts/release-package-map.mjs set-version ", "", ].join("\n"), @@ -152,20 +250,36 @@ function usage() { } const [command, arg] = process.argv.slice(2); +const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); -if (command === "list") { - listPackages(); - process.exit(0); -} - -if (command === "set-version") { - if (!arg) { - usage(); - process.exit(1); +if (isDirectRun) { + if (command === "list") { + listPackages(); + process.exit(0); } - setVersion(arg); - process.exit(0); + + if (command === "check") { + checkConfiguration(); + process.exit(0); + } + + if (command === "set-version") { + if (!arg) { + usage(); + process.exit(1); + } + setVersion(arg); + process.exit(0); + } + + usage(); + process.exit(1); } -usage(); -process.exit(1); +export { + buildReleasePackagePlan, + checkConfiguration, + discoverPublicPackages, + getReleasePackages, + loadReleaseManifest, +}; diff --git a/scripts/release-package-map.test.mjs b/scripts/release-package-map.test.mjs new file mode 100644 index 00000000000..704dd3ddbec --- /dev/null +++ b/scripts/release-package-map.test.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildReleasePackagePlan, + checkConfiguration, + getReleasePackages, +} from "./release-package-map.mjs"; + +test("release package manifest covers all public packages with explicit CI enrollment", () => { + const packages = buildReleasePackagePlan(); + assert.ok(packages.length > 0); + assert.ok(packages.every((pkg) => typeof pkg.publishFromCi === "boolean")); +}); + +test("release package list only contains CI-enrolled packages", () => { + const enabledPackages = getReleasePackages(); + assert.ok(enabledPackages.length > 0); + assert.ok(enabledPackages.every((pkg) => pkg.publishFromCi === true)); +}); + +test("release package configuration validates successfully", () => { + assert.doesNotThrow(() => checkConfiguration()); +}); diff --git a/server/src/__tests__/heartbeat-plugin-environment.test.ts b/server/src/__tests__/heartbeat-plugin-environment.test.ts index 46de3f47f42..9e92f260439 100644 --- a/server/src/__tests__/heartbeat-plugin-environment.test.ts +++ b/server/src/__tests__/heartbeat-plugin-environment.test.ts @@ -38,6 +38,7 @@ vi.mock("../adapters/index.js", () => ({ execute: adapterExecute, supportsLocalAgentJwt: false, }), + listAdapterModelProfiles: async () => [], runningProcesses: new Map(), })); @@ -70,6 +71,7 @@ describeEmbeddedPostgres("heartbeat plugin environments", () => { }); afterAll(async () => { + await db.$client.end(); await stopDb?.(); }); From ae23e02526968a6eb7ea5a609f88f50889bfb399 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 3 May 2026 21:50:23 -0500 Subject: [PATCH 14/27] Support Cloud tenant identity bootstrap Co-Authored-By: Paperclip --- .../src/__tests__/auth-session-route.test.ts | 77 ++++++++- server/src/middleware/auth.ts | 157 +++++++++++++++++- server/src/types/express.d.ts | 2 +- 3 files changed, 232 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/auth-session-route.test.ts b/server/src/__tests__/auth-session-route.test.ts index 91eeb8a07f2..65f6cbd33a1 100644 --- a/server/src/__tests__/auth-session-route.test.ts +++ b/server/src/__tests__/auth-session-route.test.ts @@ -1,6 +1,6 @@ import express from "express"; import request from "supertest"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { actorMiddleware } from "../middleware/auth.js"; function createSelectChain(rows: unknown[]) { @@ -25,6 +25,13 @@ function createDb() { } describe("actorMiddleware authenticated session profile", () => { + const originalCloudTenantToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + + afterEach(() => { + if (originalCloudTenantToken === undefined) delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + else process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = originalCloudTenantToken; + }); + it("preserves the signed-in user name and email on the board actor", async () => { const app = express(); app.use( @@ -58,4 +65,72 @@ describe("actorMiddleware authenticated session profile", () => { isInstanceAdmin: false, }); }); + + it("trusts Cloud tenant identity headers and seeds board access", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-token"; + const inserts: Array<{ values: Record }> = []; + const db = { + insert: vi.fn(() => { + const chain = { + values(values: Record) { + inserts.push({ values }); + return chain; + }, + onConflictDoUpdate() { + return chain; + }, + onConflictDoNothing() { + return chain; + }, + returning() { + return Promise.resolve([{ + companyId: inserts.at(-1)?.values.companyId, + membershipRole: inserts.at(-1)?.values.membershipRole, + status: inserts.at(-1)?.values.status, + }]); + }, + }; + return chain; + }), + select: vi.fn(), + } as any; + const app = express(); + app.use( + actorMiddleware(db, { + deploymentMode: "authenticated", + resolveSession: async () => null, + }), + ); + app.get("/actor", (req, res) => { + res.json(req.actor); + }); + + const res = await request(app) + .get("/actor") + .set("x-paperclip-cloud-tenant-token", "tenant-token") + .set("x-paperclip-cloud-user-id", "global-user-1") + .set("x-paperclip-cloud-user-email", "owner@example.com") + .set("x-paperclip-cloud-user-name", "Stack Owner") + .set("x-paperclip-cloud-stack-id", "stack-alpha") + .set("x-paperclip-cloud-paperclip-company-id", "paperclip-stack-alpha") + .set("x-paperclip-cloud-stack-role", "owner"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + type: "board", + userId: "global-user-1", + userName: "Stack Owner", + userEmail: "owner@example.com", + source: "cloud_tenant", + isInstanceAdmin: true, + memberships: [expect.objectContaining({ membershipRole: "owner", status: "active" })], + }); + expect(res.body.companyIds[0]).toMatch(/^[0-9a-f-]{36}$/); + expect(inserts).toHaveLength(4); + expect(inserts[0]?.values).toMatchObject({ + id: "global-user-1", + email: "owner@example.com", + emailVerified: true, + }); + }); }); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index 2438aee3069..ad0b4289bb3 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -1,8 +1,8 @@ -import { createHash } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; import type { Request, RequestHandler } from "express"; import { and, eq, isNull } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { agentApiKeys, agents, companyMemberships, instanceUserRoles } from "@paperclipai/db"; +import { agentApiKeys, agents, authUsers, companies, companyMemberships, instanceUserRoles } from "@paperclipai/db"; import { verifyLocalAgentJwt } from "../agent-auth-jwt.js"; import type { DeploymentMode } from "@paperclipai/shared"; import type { BetterAuthSessionResult } from "../auth/better-auth.js"; @@ -38,6 +38,16 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa const authHeader = req.header("authorization"); if (!authHeader?.toLowerCase().startsWith("bearer ")) { if (opts.deploymentMode === "authenticated" && opts.resolveSession) { + const cloudTenantActor = await resolveCloudTenantActor(db, req); + if (cloudTenantActor) { + req.actor = { + ...cloudTenantActor, + runId: runIdHeader ?? undefined, + }; + next(); + return; + } + let session: BetterAuthSessionResult | null = null; try { session = await opts.resolveSession(req); @@ -189,6 +199,149 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa }; } +async function resolveCloudTenantActor(db: Db, req: Request): Promise { + const expectedToken = process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN?.trim(); + if (!expectedToken) return null; + + const token = req.header("x-paperclip-cloud-tenant-token")?.trim(); + if (!token || !constantTimeStringEqual(token, expectedToken)) return null; + + const userId = requiredCloudHeader(req, "x-paperclip-cloud-user-id"); + const userEmail = requiredCloudHeader(req, "x-paperclip-cloud-user-email").toLowerCase(); + const stackId = requiredCloudHeader(req, "x-paperclip-cloud-stack-id"); + const stackRole = stackMembershipRole(req.header("x-paperclip-cloud-stack-role")); + const userName = req.header("x-paperclip-cloud-user-name")?.trim() || userEmail; + const paperclipCompanyId = req.header("x-paperclip-cloud-paperclip-company-id")?.trim(); + const companyId = cloudTenantCompanyId(stackId); + const companyName = paperclipCompanyId || `${stackId} Paperclip`; + const now = new Date(); + + await db + .insert(authUsers) + .values({ + id: userId, + name: userName, + email: userEmail, + emailVerified: true, + image: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: authUsers.id, + set: { + name: userName, + email: userEmail, + emailVerified: true, + updatedAt: now, + }, + }); + + await db + .insert(instanceUserRoles) + .values({ + userId, + role: "instance_admin", + updatedAt: now, + }) + .onConflictDoNothing({ + target: [instanceUserRoles.userId, instanceUserRoles.role], + }); + + await db + .insert(companies) + .values({ + id: companyId, + name: companyName, + description: `Provisioned by Paperclip Cloud for stack ${stackId}.`, + status: "active", + issuePrefix: issuePrefixForCloudStack(stackId), + updatedAt: now, + }) + .onConflictDoNothing({ + target: companies.id, + }); + + const membershipRole = stackRole === "owner" || stackRole === "admin" ? "owner" : stackRole; + const membership = await db + .insert(companyMemberships) + .values({ + companyId, + principalType: "user", + principalId: userId, + status: "active", + membershipRole, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [ + companyMemberships.companyId, + companyMemberships.principalType, + companyMemberships.principalId, + ], + set: { + status: "active", + membershipRole, + updatedAt: now, + }, + }) + .returning() + .then((rows) => rows[0] ?? { + companyId, + membershipRole, + status: "active", + }); + + return { + type: "board", + userId, + userName, + userEmail, + companyIds: [companyId], + memberships: [{ + companyId, + membershipRole: membership.membershipRole, + status: membership.status, + }], + isInstanceAdmin: true, + source: "cloud_tenant", + }; +} + +function requiredCloudHeader(req: Request, name: string): string { + const value = req.header(name)?.trim(); + if (!value) { + throw new Error(`Missing trusted Cloud tenant header ${name}`); + } + return value; +} + +function stackMembershipRole(value: string | undefined): "owner" | "admin" | "member" | "support" { + if (value === "owner" || value === "admin" || value === "member" || value === "support") { + return value; + } + throw new Error("Invalid trusted Cloud tenant stack role"); +} + +function constantTimeStringEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function cloudTenantCompanyId(stackId: string): string { + const bytes = createHash("sha256").update(`paperclip-cloud-tenant-company:${stackId}`).digest(); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.subarray(0, 16).toString("hex"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`; +} + +function issuePrefixForCloudStack(stackId: string): string { + const hash = createHash("sha256").update(stackId).digest("hex").slice(0, 4).toUpperCase(); + return `PC${hash}`; +} + export function requireBoard(req: Express.Request) { return req.actor.type === "board"; } diff --git a/server/src/types/express.d.ts b/server/src/types/express.d.ts index 1915e2ffdb1..17f94e752e9 100644 --- a/server/src/types/express.d.ts +++ b/server/src/types/express.d.ts @@ -19,7 +19,7 @@ declare global { isInstanceAdmin?: boolean; keyId?: string; runId?: string; - source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "none"; + source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant" | "none"; }; } } From 47920f9c477c6a3b976f3f3d190f39db991ca9ce Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 3 May 2026 20:20:14 -0700 Subject: [PATCH 15/27] Speed up PR CI critical path (#5147) ## Thinking Path > - Paperclip orchestrates AI agents for autonomous companies, so developer throughput on the control plane repo directly affects how fast the product can evolve. > - The PR workflow is part of that throughput surface because every change waits on it before review and merge. > - This branch started from measured evidence that the PR critical path was dominated by work that was either serialized unnecessarily or placed on the wrong part of the graph. > - The biggest concrete problems were: the canary dry run living inside `verify`, the server isolated suites running one-by-one in a single lane, and duplicate CI work that the PR path was paying for without increasing coverage proportionally. > - This pull request restructures the PR workflow so those costs are reduced without removing the important coverage that was already protecting release and test quality. > - Follow-up fixes on the branch hardened the new entrypoints so they work on clean GitHub runners and so the reduced PR typecheck path stays self-maintaining as workspace packages evolve. > - The benefit is materially faster PR wall-clock time while keeping canary packaging checks, serialized-suite isolation, plugin SDK consumers, and explicit TypeScript coverage where builds do not already provide it. ## What Changed - Moved the PR canary dry run into its own `Canary Dry Run` job so it still runs on PRs but no longer extends the `verify` critical path. - Split the custom Vitest runner into `general`, `serialized`, and `all` modes, and added shard support for the isolated server suites. - Added `test:run:general` and `test:run:serialized` scripts, then rewired PR CI to fan the serialized server suites out across a 4-way matrix. - Added the required `@paperclipai/plugin-sdk` build preflight before the new reduced-scope typecheck and test entrypoints so they succeed on clean CI runners. - Replaced the hardcoded PR build-gap list with `scripts/run-typecheck-build-gaps.mjs`, which discovers workspace packages whose `build` scripts skip TypeScript and runs only their explicit `typecheck` scripts. - Removed the redundant `pnpm build` from the PR `e2e` job because the Playwright onboarding path boots Paperclip from source. ## Verification - `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/pr.yml'); puts 'workflow ok'"` - `node scripts/run-vitest-stable.mjs --mode general --dry-run` - `node scripts/run-vitest-stable.mjs --mode serialized --shard-index 0 --shard-count 4 --dry-run` - `pnpm run typecheck:build-gaps` - `pnpm test:run:general` - `pnpm test:run:serialized -- --shard-index 0 --shard-count 4` - `pnpm build` - `pnpm paperclipai onboard --yes --run` - `curl http://127.0.0.1:3299/api/health` ## Risks - Branch protection or required-check configuration may need to be updated for the new standalone `Canary Dry Run` job and the serialized-suite matrix job names. - `scripts/run-typecheck-build-gaps.mjs` assumes packages that need explicit PR-time typechecking are the ones whose `build` scripts omit `tsc`; if build conventions change, that heuristic needs to stay aligned. - Serialized test sharding preserves per-suite isolation, but the first few CI runs should still be watched for shard-balance or naming assumptions in downstream tooling. ## Model Used - OpenAI GPT-5.4 via the Codex local adapter, using high reasoning effort with shell, git, and file-edit tool use in a local worktree. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .github/workflows/pr.yml | 82 +++++++++-- package.json | 3 + scripts/run-typecheck-build-gaps.mjs | 93 ++++++++++++ scripts/run-vitest-stable.mjs | 206 ++++++++++++++++++++++++--- 4 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 scripts/run-typecheck-build-gaps.mjs diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8ac8f710ad5..fabaab1b710 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -85,11 +85,11 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Typecheck - run: pnpm -r typecheck + - name: Typecheck workspaces whose build scripts skip TypeScript + run: pnpm run typecheck:build-gaps - - name: Run tests - run: pnpm test:run + - name: Run general test suites + run: pnpm test:run:general - name: Verify release registry test coverage run: pnpm run test:release-registry @@ -97,7 +97,76 @@ jobs: - name: Build run: pnpm build - - name: Release canary dry run + verify_serialized_server: + name: Verify serialized server suites (${{ matrix.shard_label }}) + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - shard_index: 0 + shard_count: 4 + shard_label: 1/4 + - shard_index: 1 + shard_count: 4 + shard_label: 2/4 + - shard_index: 2 + shard_count: 4 + shard_label: 3/4 + - shard_index: 3 + shard_count: 4 + shard_label: 4/4 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run serialized server test shard + run: pnpm test:run:serialized -- --shard-index ${{ matrix.shard_index }} --shard-count ${{ matrix.shard_count }} + + canary_dry_run: + name: Canary Dry Run + needs: [policy] + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # `release.sh` always executes its Step 2/7 workspace build, even when + # `--skip-verify` bypasses the initial verification gate. + - name: Release canary dry run via release.sh internal build run: | git checkout -B master HEAD git checkout -- pnpm-lock.yaml @@ -126,9 +195,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build - run: pnpm build - - name: Install Playwright run: npx playwright install --with-deps chromium diff --git a/package.json b/package.json index 9d6b5bec65e..152fe4711e3 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,12 @@ "build-storybook": "pnpm --filter @paperclipai/ui build-storybook", "build": "pnpm run preflight:workspace-links && pnpm -r build", "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", + "typecheck:build-gaps": "pnpm run preflight:workspace-links && node scripts/run-typecheck-build-gaps.mjs", "test": "pnpm run test:run", "test:watch": "pnpm run preflight:workspace-links && vitest", "test:run": "pnpm run preflight:workspace-links && node scripts/run-vitest-stable.mjs", + "test:run:general": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode general", + "test:run:serialized": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && node scripts/run-vitest-stable.mjs --mode serialized", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "issue-references:backfill": "pnpm run preflight:workspace-links && tsx scripts/backfill-issue-reference-mentions.ts", diff --git a/scripts/run-typecheck-build-gaps.mjs b/scripts/run-typecheck-build-gaps.mjs new file mode 100644 index 00000000000..6210ab2a17e --- /dev/null +++ b/scripts/run-typecheck-build-gaps.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const repoRoot = process.cwd(); + +function fail(message) { + console.error(`[typecheck:build-gaps] ${message}`); + process.exit(1); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: repoRoot, + stdio: "inherit", + }); + + if (result.error) { + console.error(`[typecheck:build-gaps] Failed to spawn ${command}: ${result.error.message}`); + process.exit(1); + } + + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, "utf8")); +} + +function listWorkspacePackages() { + const result = spawnSync("pnpm", ["ls", "-r", "--depth", "-1", "--json"], { + cwd: repoRoot, + encoding: "utf8", + }); + + if (result.error) { + fail(`Unable to spawn pnpm to list workspace packages: ${result.error.message}`); + } + + if (result.status !== 0) { + fail("Unable to list pnpm workspace packages."); + } + + return JSON.parse(result.stdout); +} + +function buildSkipsTypeScript(pkg) { + const buildScript = pkg.scripts?.build; + if (typeof buildScript !== "string") { + return false; + } + + return !/\btsc\b/.test(buildScript); +} + +const workspacePackages = listWorkspacePackages(); +const buildGapCandidates = workspacePackages + .filter((workspacePkg) => workspacePkg.path !== repoRoot) + .map((workspacePkg) => ({ + name: workspacePkg.name, + path: workspacePkg.path, + pkg: readJson(path.join(workspacePkg.path, "package.json")), + })) + .filter(({ pkg }) => buildSkipsTypeScript(pkg)); +const packagesMissingTypecheck = buildGapCandidates.filter( + ({ pkg }) => typeof pkg.scripts?.typecheck !== "string", +); +if (packagesMissingTypecheck.length > 0) { + const missingNames = packagesMissingTypecheck.map((workspacePkg) => workspacePkg.name).join(", "); + fail( + `Workspace packages with build scripts that skip tsc must define a typecheck script. Missing: ${missingNames}`, + ); +} +const buildGapPackages = buildGapCandidates.filter( + ({ pkg }) => typeof pkg.scripts?.typecheck === "string", +); + +console.log( + `[typecheck:build-gaps] typechecking ${buildGapPackages.length} workspace(s): ${buildGapPackages.map(({ name }) => name).join(", ") || "(none)"}`, +); + +if (buildGapPackages.length === 0) { + process.exit(0); +} + +run("pnpm", ["--filter", "@paperclipai/plugin-sdk", "build"]); + +for (const workspacePkg of buildGapPackages) { + run("pnpm", ["--filter", workspacePkg.name, "typecheck"]); +} diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index e016719f0d6..4ded9794581 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -45,6 +45,9 @@ const additionalSerializedServerTests = new Set([ "server/src/__tests__/routines-e2e.test.ts", ]); let invocationIndex = 0; +const serializedModeName = "serialized"; +const generalModeName = "general"; +const allModeName = "all"; function walk(dir) { const entries = readdirSync(dir); @@ -77,6 +80,130 @@ function isRouteOrAuthzTest(file) { return additionalSerializedServerTests.has(file); } +function fail(message) { + console.error(`[test:run] ${message}`); + process.exit(1); +} + +function readOptionValue(argv, index, argName) { + const value = argv[index + 1]; + if (value === undefined) { + fail(`Missing value for ${argName}`); + } + + return value; +} + +function parseNonNegativeInteger(value, argName) { + const parsed = Number(value); + if (value.trim() === "" || !Number.isInteger(parsed) || parsed < 0) { + fail(`${argName} must be a non-negative integer. Received "${value}".`); + } + + return parsed; +} + +function parsePositiveInteger(value, argName) { + const parsed = Number(value); + if (value.trim() === "" || !Number.isInteger(parsed) || parsed < 1) { + fail(`${argName} must be a positive integer. Received "${value}".`); + } + + return parsed; +} + +function parseCliOptions(argv) { + let mode = allModeName; + let shardIndex = null; + let shardCount = null; + let dryRun = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--") { + continue; + } + + if (arg === "--mode") { + mode = readOptionValue(argv, index, arg); + index += 1; + continue; + } + + if (arg.startsWith("--mode=")) { + mode = arg.slice("--mode=".length); + continue; + } + + if (arg === "--shard-index") { + shardIndex = parseNonNegativeInteger(readOptionValue(argv, index, arg), arg); + index += 1; + continue; + } + + if (arg.startsWith("--shard-index=")) { + shardIndex = parseNonNegativeInteger(arg.slice("--shard-index=".length), "--shard-index"); + continue; + } + + if (arg === "--shard-count") { + shardCount = parsePositiveInteger(readOptionValue(argv, index, arg), arg); + index += 1; + continue; + } + + if (arg.startsWith("--shard-count=")) { + shardCount = parsePositiveInteger(arg.slice("--shard-count=".length), "--shard-count"); + continue; + } + + if (arg === "--dry-run") { + dryRun = true; + continue; + } + + fail(`Unknown argument "${arg}".`); + } + + if (!new Set([allModeName, generalModeName, serializedModeName]).has(mode)) { + fail(`Unknown mode "${mode}". Expected one of: ${allModeName}, ${generalModeName}, ${serializedModeName}.`); + } + + if ((shardIndex === null) !== (shardCount === null)) { + fail("--shard-index and --shard-count must be provided together."); + } + + if (mode !== serializedModeName && shardIndex !== null) { + fail("--shard-index/--shard-count are only valid with --mode serialized."); + } + + if (mode === serializedModeName) { + const resolvedShardCount = shardCount ?? 1; + const resolvedShardIndex = shardIndex ?? 0; + if (resolvedShardIndex >= resolvedShardCount) { + fail(`--shard-index must be less than --shard-count. Received ${resolvedShardIndex} of ${resolvedShardCount}.`); + } + + return { + mode, + shardIndex: resolvedShardIndex, + shardCount: resolvedShardCount, + dryRun, + }; + } + + return { + mode, + shardIndex: null, + shardCount: null, + dryRun, + }; +} + +function selectSerializedSuites(routeTests, shardIndex, shardCount) { + return routeTests.filter((_, index) => index % shardCount === shardIndex); +} + function runVitest(args, label) { console.log(`\n[test:run] ${label}`); invocationIndex += 1; @@ -103,6 +230,38 @@ function runVitest(args, label) { } } +function runGeneralSuites(routeTests) { + const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); + for (const project of nonServerProjects) { + runVitest(["--project", project], `non-server project ${project}`); + } + + runVitest( + ["--project", "@paperclipai/server", ...excludeRouteArgs], + `server suites excluding ${routeTests.length} serialized suites`, + ); +} + +function runSerializedSuites(routeTests, shardIndex, shardCount) { + const shardTests = selectSerializedSuites(routeTests, shardIndex, shardCount); + console.log( + `\n[test:run] serialized shard ${shardIndex + 1}/${shardCount} running ${shardTests.length} of ${routeTests.length} suites`, + ); + + for (const routeTest of shardTests) { + runVitest( + [ + "--project", + "@paperclipai/server", + routeTest.repoPath, + "--pool=forks", + "--poolOptions.forks.isolate=true", + ], + routeTest.repoPath, + ); + } +} + const routeTests = walk(serverTestsDir) .filter((file) => isRouteOrAuthzTest(toRepoPath(file))) .map((file) => ({ @@ -111,25 +270,32 @@ const routeTests = walk(serverTestsDir) })) .sort((a, b) => a.repoPath.localeCompare(b.repoPath)); -const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); -for (const project of nonServerProjects) { - runVitest(["--project", project], `non-server project ${project}`); -} - -runVitest( - ["--project", "@paperclipai/server", ...excludeRouteArgs], - `server suites excluding ${routeTests.length} serialized suites`, -); - -for (const routeTest of routeTests) { - runVitest( - [ - "--project", - "@paperclipai/server", - routeTest.repoPath, - "--pool=forks", - "--poolOptions.forks.isolate=true", - ], - routeTest.repoPath, +const options = parseCliOptions(process.argv.slice(2)); +if (options.dryRun) { + const serializedSuites = + options.mode === serializedModeName + ? selectSerializedSuites(routeTests, options.shardIndex, options.shardCount) + : routeTests; + console.log( + JSON.stringify( + { + mode: options.mode, + shardIndex: options.shardIndex, + shardCount: options.shardCount, + serializedSuiteCount: routeTests.length, + selectedSerializedSuites: serializedSuites.map((routeTest) => routeTest.repoPath), + }, + null, + 2, + ), ); + process.exit(0); +} + +if (options.mode === generalModeName || options.mode === allModeName) { + runGeneralSuites(routeTests); +} + +if (options.mode === serializedModeName || options.mode === allModeName) { + runSerializedSuites(routeTests, options.shardIndex ?? 0, options.shardCount ?? 1); } From fd10404374a64a458511ed395c4f82c5774647d2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 3 May 2026 22:52:33 -0500 Subject: [PATCH 16/27] Raise Docker image build timeout Co-Authored-By: Paperclip --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 490290c2620..08ff2462f0d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -14,7 +14,7 @@ permissions: jobs: build-and-push: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 concurrency: group: docker-${{ github.ref }} cancel-in-progress: true From d6bee62f02de058a4f12ac9a10dedf8c58c4b1d5 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 4 May 2026 13:20:58 -0500 Subject: [PATCH 17/27] Fix Cloud tenant issue identifier routes (#5196) ## Summary - Allow Cloud tenant issue identifiers with alphanumeric prefixes, such as `PC1897-1`, to normalize as issue references. - Resolve those identifiers through issue detail/update routes, active run/live run polling, activity, costs, and `issueService.getById`. - Keep UI issue-link parsing aligned so tenant links normalize back to `/issues/`. ## Root Cause Cloud tenant issue prefixes include digits from the stack-id hash. The app-side route normalization still accepted only all-letter prefixes, so `/api/issues/PC1897-1` skipped identifier lookup and fell through as a non-UUID id. ## Verification - `pnpm exec vitest run packages/shared/src/issue-references.test.ts ui/src/lib/issue-reference.test.ts server/src/__tests__/issue-identifier-routes.test.ts server/src/__tests__/activity-routes.test.ts server/src/__tests__/costs-service.test.ts server/src/__tests__/agent-live-run-routes.test.ts server/src/__tests__/issues-service.test.ts` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/server typecheck` - `git diff --check` Co-authored-by: Paperclip --- packages/shared/src/issue-references.test.ts | 13 ++- packages/shared/src/issue-references.ts | 4 +- server/src/__tests__/activity-routes.test.ts | 6 +- .../__tests__/agent-live-run-routes.test.ts | 6 +- server/src/__tests__/costs-service.test.ts | 8 +- .../__tests__/issue-identifier-routes.test.ts | 105 ++++++++++++++++++ server/src/__tests__/issues-service.test.ts | 10 +- server/src/routes/activity.ts | 6 +- server/src/routes/agents.ts | 9 +- server/src/routes/costs.ts | 6 +- server/src/routes/issues.ts | 12 +- server/src/services/issues.ts | 13 ++- ui/src/lib/issue-reference.test.ts | 5 + ui/src/lib/issue-reference.ts | 4 +- 14 files changed, 166 insertions(+), 41 deletions(-) create mode 100644 server/src/__tests__/issue-identifier-routes.test.ts diff --git a/packages/shared/src/issue-references.test.ts b/packages/shared/src/issue-references.test.ts index fa091c412a0..d4af275ae8c 100644 --- a/packages/shared/src/issue-references.test.ts +++ b/packages/shared/src/issue-references.test.ts @@ -10,6 +10,7 @@ import { describe("issue references", () => { it("normalizes identifiers to uppercase", () => { expect(normalizeIssueIdentifier("pap-123")).toBe("PAP-123"); + expect(normalizeIssueIdentifier("pc1a2-7")).toBe("PC1A2-7"); expect(normalizeIssueIdentifier("not-an-issue")).toBeNull(); }); @@ -27,14 +28,14 @@ describe("issue references", () => { }); it("finds identifiers and issue paths in plain text", () => { - expect(findIssueReferenceMatches("See PAP-1, /issues/PAP-2, and https://x.test/PAP/issues/pap-3.")).toEqual([ + expect(findIssueReferenceMatches("See PAP-1, /issues/PC1A2-2, and https://x.test/PAP/issues/pc1a2-3.")).toEqual([ { index: 4, length: 5, identifier: "PAP-1", matchedText: "PAP-1" }, - { index: 11, length: 13, identifier: "PAP-2", matchedText: "/issues/PAP-2" }, + { index: 11, length: 15, identifier: "PC1A2-2", matchedText: "/issues/PC1A2-2" }, { - index: 30, - length: 31, - identifier: "PAP-3", - matchedText: "https://x.test/PAP/issues/pap-3", + index: 32, + length: 33, + identifier: "PC1A2-3", + matchedText: "https://x.test/PAP/issues/pc1a2-3", }, ]); }); diff --git a/packages/shared/src/issue-references.ts b/packages/shared/src/issue-references.ts index eb45ff9a815..bfdd2126fb1 100644 --- a/packages/shared/src/issue-references.ts +++ b/packages/shared/src/issue-references.ts @@ -1,4 +1,4 @@ -export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z]+-\d+$/; +export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]*-\d+$/; export interface IssueReferenceMatch { index: number; @@ -7,7 +7,7 @@ export interface IssueReferenceMatch { matchedText: string; } -const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z]+-\d+/gi; +const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z][A-Z0-9]*-\d+/gi; function preserveNewlinesAsWhitespace(value: string) { return value.replace(/[^\n]/g, " "); diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index f505980db4d..270673917df 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -128,7 +128,7 @@ describe.sequential("activity routes", () => { }); }); - it("resolves issue identifiers before loading runs", async () => { + it("resolves alphanumeric issue identifiers before loading runs", async () => { mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-uuid-1", companyId: "company-1", @@ -141,10 +141,10 @@ describe.sequential("activity routes", () => { ]); const app = await createApp(); - const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/PAP-475/runs")); + const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/issues/pc1a2-475/runs")); expect(res.status).toBe(200); - expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); + expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-475"); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts index 35434b86ff3..470ef85d5eb 100644 --- a/server/src/__tests__/agent-live-run-routes.test.ts +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -215,11 +215,11 @@ describe("agent live run routes", () => { it("returns a compact active run payload for issue polling", async () => { const res = await requestApp( await createApp(), - (baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"), + (baseUrl) => request(baseUrl).get("/api/issues/pc1a2-1295/active-run"), ); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295"); + expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1295"); expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1"); expect(res.body).toMatchObject({ id: "run-1", @@ -268,7 +268,7 @@ describe("agent live run routes", () => { const res = await requestApp( await createApp(), - (baseUrl) => request(baseUrl).get("/api/issues/PAP-1295/active-run"), + (baseUrl) => request(baseUrl).get("/api/issues/PC1A2-1295/active-run"), ); expect(res.status, JSON.stringify(res.body)).toBe(200); diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index f9e32f0f1e7..38e1f671942 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -178,12 +178,12 @@ beforeEach(() => { mockIssueService.getById.mockResolvedValue({ id: "issue-1", companyId: "company-1", - identifier: "PAP-1", + identifier: "PC1A2-1", }); mockIssueService.getByIdentifier.mockResolvedValue({ id: "issue-1", companyId: "company-1", - identifier: "PAP-1", + identifier: "PC1A2-1", }); mockBudgetService.upsertPolicy.mockResolvedValue(undefined); }); @@ -227,10 +227,10 @@ describe("cost routes", () => { it("returns issue subtree cost summaries for issue refs", async () => { const app = await createApp(); - const res = await request(app).get("/api/issues/PAP-1/cost-summary"); + const res = await request(app).get("/api/issues/pc1a2-1/cost-summary"); expect(res.status).toBe(200); - expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1"); + expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PC1A2-1"); expect(mockCostService.issueTreeSummary).toHaveBeenCalledWith("company-1", "issue-1"); expect(res.body).toEqual({ issueId: "issue-1", diff --git a/server/src/__tests__/issue-identifier-routes.test.ts b/server/src/__tests__/issue-identifier-routes.test.ts new file mode 100644 index 00000000000..1a204db5220 --- /dev/null +++ b/server/src/__tests__/issue-identifier-routes.test.ts @@ -0,0 +1,105 @@ +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { companies, createDb, issues } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue identifier route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("issue identifier routes", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-identifier-routes-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + function createApp(companyId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "cloud-user-1", + companyIds: [companyId], + memberships: [{ companyId, membershipRole: "owner", status: "active" }], + source: "cloud_tenant", + isInstanceAdmin: true, + }; + next(); + }); + app.use("/api", issueRoutes(db, {} as any)); + app.use(errorHandler); + return app; + } + + it("resolves alphanumeric Cloud tenant issue identifiers for detail reads and updates", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Cloud tenant", + issuePrefix: "PC1A2", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values({ + id: issueId, + companyId, + issueNumber: 7, + identifier: "PC1A2-7", + title: "Tenant identifier route", + status: "todo", + priority: "medium", + createdByUserId: "cloud-user-1", + }); + + const app = createApp(companyId); + const read = await request(app).get("/api/issues/pc1a2-7"); + + expect(read.status, JSON.stringify(read.body)).toBe(200); + expect(read.body).toMatchObject({ + id: issueId, + companyId, + identifier: "PC1A2-7", + }); + + const updated = await request(app) + .patch("/api/issues/PC1A2-7") + .send({ priority: "high" }); + + expect(updated.status, JSON.stringify(updated.body)).toBe(200); + expect(updated.body).toMatchObject({ + id: issueId, + companyId, + identifier: "PC1A2-7", + priority: "high", + }); + + const stored = await db + .select({ priority: issues.priority }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(stored?.priority).toBe("high"); + }); +}); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 3e2ab7940fa..eeb82316cc2 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -460,14 +460,14 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([grandchildId]); }); - it("accepts issue identifiers through getById", async () => { + it("accepts issue identifiers with alphanumeric prefixes through getById", async () => { const companyId = randomUUID(); const issueId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Paperclip", - issuePrefix: "PAP", + issuePrefix: "PC1A2", requireBoardApprovalForNewAgents: false, }); @@ -475,19 +475,19 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { id: issueId, companyId, issueNumber: 1064, - identifier: "PAP-1064", + identifier: "PC1A2-1064", title: "Feedback votes error", status: "todo", priority: "medium", createdByUserId: "user-1", }); - const issue = await svc.getById("PAP-1064"); + const issue = await svc.getById("pc1a2-1064"); expect(issue).toEqual( expect.objectContaining({ id: issueId, - identifier: "PAP-1064", + identifier: "PC1A2-1064", }), ); }); diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 831bc0e7321..590ed797921 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import { z } from "zod"; import type { Db } from "@paperclipai/db"; +import { normalizeIssueIdentifier } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { activityService, normalizeActivityLimit } from "../services/activity.js"; import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js"; @@ -24,8 +25,9 @@ export function activityRoutes(db: Db) { const issueSvc = issueService(db); async function resolveIssueByRef(rawId: string) { - if (/^[A-Z]+-\d+$/i.test(rawId)) { - return issueSvc.getByIdentifier(rawId); + const identifier = normalizeIssueIdentifier(rawId); + if (identifier) { + return issueSvc.getByIdentifier(identifier); } return issueSvc.getById(rawId); } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 3e92cacf887..81e165ddd00 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -13,6 +13,7 @@ import { createAgentSchema, deriveAgentUrlKey, isUuidLike, + normalizeIssueIdentifier, resetAgentSessionSchema, testAdapterEnvironmentSchema, type AgentSkillSnapshot, @@ -3088,8 +3089,8 @@ export function agentRoutes( router.get("/issues/:issueId/live-runs", async (req, res) => { const rawId = req.params.issueId as string; const issueSvc = issueService(db); - const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId); - const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId); + const identifier = normalizeIssueIdentifier(rawId); + const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; @@ -3142,8 +3143,8 @@ export function agentRoutes( router.get("/issues/:issueId/active-run", async (req, res) => { const rawId = req.params.issueId as string; const issueSvc = issueService(db); - const isIdentifier = /^[A-Z]+-\d+$/i.test(rawId); - const issue = isIdentifier ? await issueSvc.getByIdentifier(rawId) : await issueSvc.getById(rawId); + const identifier = normalizeIssueIdentifier(rawId); + const issue = identifier ? await issueSvc.getByIdentifier(identifier) : await issueSvc.getById(rawId); if (!issue) { res.status(404).json({ error: "Issue not found" }); return; diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index 3f3514e69fb..6e65e33f146 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { createCostEventSchema, createFinanceEventSchema, + normalizeIssueIdentifier, resolveBudgetIncidentSchema, updateBudgetSchema, upsertBudgetPolicySchema, @@ -62,8 +63,9 @@ export function costRoutes( const issues = issueService(db); async function resolveIssueByRef(rawId: string) { - if (/^[A-Z]+-\d+$/i.test(rawId)) { - return issues.getByIdentifier(rawId); + const identifier = normalizeIssueIdentifier(rawId); + if (identifier) { + return issues.getByIdentifier(identifier); } return issues.getById(rawId); } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4a6492b531c..f37fbaa83b5 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -30,6 +30,7 @@ import { updateIssueSchema, getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, + normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, type ExecutionWorkspace, } from "@paperclipai/shared"; import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry"; @@ -880,9 +881,10 @@ export function issueRoutes( }); } - async function normalizeIssueIdentifier(rawId: string): Promise { - if (/^[A-Z]+-\d+$/i.test(rawId)) { - const issue = await svc.getByIdentifier(rawId); + async function resolveIssueRouteId(rawId: string): Promise { + const identifier = normalizeIssueReferenceIdentifier(rawId); + if (identifier) { + const issue = await svc.getByIdentifier(identifier); if (issue) { return issue.id; } @@ -920,7 +922,7 @@ export function issueRoutes( // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes router.param("id", async (req, res, next, rawId) => { try { - req.params.id = await normalizeIssueIdentifier(rawId); + req.params.id = await resolveIssueRouteId(rawId); next(); } catch (err) { next(err); @@ -930,7 +932,7 @@ export function issueRoutes( // Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes. router.param("issueId", async (req, res, next, rawId) => { try { - req.params.issueId = await normalizeIssueIdentifier(rawId); + req.params.issueId = await resolveIssueRouteId(rawId); next(); } catch (err) { next(err); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 48ddc5616b3..66c83389acc 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -33,7 +33,13 @@ import type { IssueProductivityReviewTrigger, IssueRelationIssueSummary, } from "@paperclipai/shared"; -import { clampIssueRequestDepth, extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared"; +import { + clampIssueRequestDepth, + extractAgentMentionIds, + extractProjectMentionIds, + isUuidLike, + normalizeIssueIdentifier as normalizeIssueReferenceIdentifier, +} from "@paperclipai/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { parseObject } from "../adapters/utils.js"; import { @@ -2418,8 +2424,9 @@ export function issueService(db: Db) { getById: async (raw: string) => { const id = raw.trim(); - if (/^[A-Z]+-\d+$/i.test(id)) { - return getIssueByIdentifier(id); + const identifier = normalizeIssueReferenceIdentifier(id); + if (identifier) { + return getIssueByIdentifier(identifier); } if (!isUuidLike(id)) { return null; diff --git a/ui/src/lib/issue-reference.test.ts b/ui/src/lib/issue-reference.test.ts index 54224bfb55c..a7d712633c4 100644 --- a/ui/src/lib/issue-reference.test.ts +++ b/ui/src/lib/issue-reference.test.ts @@ -5,6 +5,7 @@ describe("issue-reference", () => { it("extracts issue ids from company-scoped issue paths", () => { expect(parseIssuePathIdFromPath("/PAP/issues/PAP-1271")).toBe("PAP-1271"); expect(parseIssuePathIdFromPath("/PAP/issues/pap-1272")).toBe("PAP-1272"); + expect(parseIssuePathIdFromPath("/PC1A2/issues/pc1a2-7")).toBe("PC1A2-7"); expect(parseIssuePathIdFromPath("/issues/PAP-1179")).toBe("PAP-1179"); expect(parseIssuePathIdFromPath("/issues/:id")).toBeNull(); }); @@ -30,6 +31,10 @@ describe("issue-reference", () => { issuePathId: "PAP-1271", href: "/issues/PAP-1271", }); + expect(parseIssueReferenceFromHref("pc1a2-7")).toEqual({ + issuePathId: "PC1A2-7", + href: "/issues/PC1A2-7", + }); expect(parseIssueReferenceFromHref("/PAP/issues/pap-1180")).toEqual({ issuePathId: "PAP-1180", href: "/issues/PAP-1180", diff --git a/ui/src/lib/issue-reference.ts b/ui/src/lib/issue-reference.ts index 616bb876237..89c1a61da8a 100644 --- a/ui/src/lib/issue-reference.ts +++ b/ui/src/lib/issue-reference.ts @@ -5,9 +5,9 @@ type MarkdownNode = { children?: MarkdownNode[]; }; -const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i; +const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]*-\d+$/i; const ISSUE_SCHEME_RE = /^issue:\/\/:?([^?#\s]+)(?:[?#].*)?$/i; -const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\/(?:[^\s<>()/]+\/)*issues\/[A-Z][A-Z0-9]+-\d+(?=$|[\s<>)\],.;!?:])|\b[A-Z][A-Z0-9]+-\d+\b/gi; +const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\/(?:[^\s<>()/]+\/)*issues\/[A-Z][A-Z0-9]*-\d+(?=$|[\s<>)\],.;!?:])|\b[A-Z][A-Z0-9]*-\d+\b/gi; export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null { if (!pathOrUrl) return null; From 3c73ed26b514144b831f02361de809008742194d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 5 May 2026 07:42:57 -0500 Subject: [PATCH 18/27] Expand plugin host surface (#5205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - The plugin system is the extension boundary for optional product capabilities > - Rich plugins need more than a worker entrypoint: they need scoped database storage, local project folders, managed agents/routines, host navigation, and reusable UI components > - The LLM Wiki work exposed those missing host surfaces while keeping plugin code outside the core control plane > - This pull request expands the core plugin host, SDK, server APIs, and UI bridge so plugins can declare and use those surfaces > - The benefit is that future plugins can integrate with Paperclip through documented, validated contracts instead of bespoke server or UI imports ## What Changed - Added plugin-managed database namespaces and migration tracking, including Drizzle schema/migration files and SQL validation for namespace isolation. - Added server support for plugin local folders, managed agents, managed routines, scoped plugin APIs, and plugin operation visibility. - Expanded shared plugin manifest/types/validators and SDK host/testing/UI exports for richer plugin surfaces. - Added reusable UI pieces for file trees, managed routines, resizable sidebars, route sidebars, and plugin bridge initialization. - Updated plugin docs and example plugins to use the expanded host and SDK surface. ## Verification - `pnpm install --frozen-lockfile` - `pnpm run preflight:workspace-links && pnpm exec vitest run packages/shared/src/validators/plugin.test.ts server/src/__tests__/plugin-database.test.ts server/src/__tests__/plugin-local-folders.test.ts server/src/__tests__/plugin-managed-agents.test.ts server/src/__tests__/plugin-managed-routines.test.ts server/src/__tests__/plugin-orchestration-apis.test.ts ui/src/api/plugins.test.ts ui/src/components/FileTree.test.tsx ui/src/components/ResizableSidebarPane.test.tsx ui/src/pages/PluginPage.test.tsx ui/src/plugins/bridge.test.ts` passed: 11 files, 67 tests. - Confirmed this PR changes 89 files and does not include `pnpm-lock.yaml` or `.github/workflows/*`. ## Risks - Medium: this expands plugin host contracts across db/shared/server/ui and includes a new core migration (`0076_useful_elektra.sql`). - The plugin database namespace validator is intentionally restrictive; plugin authors may need follow-up affordances for SQL patterns that remain blocked. - Merge this before the LLM Wiki plugin PR so the plugin can resolve the new SDK and host APIs. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled shell/git/GitHub workflow. Context window size was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/plugins/PLUGIN_AUTHORING_GUIDE.md | 185 +- doc/plugins/PLUGIN_SPEC.md | 19 +- .../db/src/migrations/0076_useful_elektra.sql | 29 + .../db/src/migrations/meta/0076_snapshot.json | 16134 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/schema/index.ts | 1 + .../db/src/schema/plugin_managed_resources.ts | 34 + .../plugins/create-paperclip-plugin/README.md | 2 +- .../src/ui/index.tsx | 234 +- .../plugin-file-browser-example/src/worker.ts | 77 +- .../src/ui/index.tsx | 84 +- packages/plugins/sdk/README.md | 188 +- packages/plugins/sdk/src/bundlers.ts | 3 +- .../plugins/sdk/src/host-client-factory.ts | 98 + packages/plugins/sdk/src/index.ts | 17 + packages/plugins/sdk/src/protocol.ts | 112 + packages/plugins/sdk/src/testing.ts | 559 +- packages/plugins/sdk/src/types.ts | 156 + packages/plugins/sdk/src/ui/components.ts | 279 + packages/plugins/sdk/src/ui/hooks.ts | 53 + packages/plugins/sdk/src/ui/index.ts | 70 + packages/plugins/sdk/src/ui/types.ts | 94 + packages/plugins/sdk/src/worker-rpc-host.ts | 106 + packages/shared/src/constants.ts | 15 + packages/shared/src/index.ts | 16 + packages/shared/src/types/index.ts | 13 +- packages/shared/src/types/plugin.ts | 194 +- packages/shared/src/types/project.ts | 13 + packages/shared/src/types/routine.ts | 13 + packages/shared/src/validators/plugin.test.ts | 72 + packages/shared/src/validators/plugin.ts | 211 +- server/src/__tests__/issues-service.test.ts | 137 + server/src/__tests__/plugin-database.test.ts | 181 +- .../__tests__/plugin-local-folders.test.ts | 263 + .../__tests__/plugin-managed-agents.test.ts | 365 + .../__tests__/plugin-managed-routines.test.ts | 249 + .../plugin-orchestration-apis.test.ts | 305 + .../src/__tests__/plugin-routes-authz.test.ts | 57 + .../plugin-scoped-api-routes.test.ts | 50 + .../plugin-sdk-orchestration-contract.test.ts | 17 + server/src/app.ts | 3 + server/src/routes/issues.ts | 3 + server/src/routes/plugins.ts | 153 + server/src/services/issues.ts | 22 +- .../services/plugin-capability-validator.ts | 19 + server/src/services/plugin-database.ts | 80 +- server/src/services/plugin-host-services.ts | 234 +- server/src/services/plugin-loader.ts | 116 +- server/src/services/plugin-local-folders.ts | 564 + server/src/services/plugin-managed-agents.ts | 508 + .../src/services/plugin-managed-routines.ts | 523 + server/src/services/plugin-registry.ts | 60 + server/src/services/projects.ts | 314 +- server/src/services/routines.ts | 112 +- ui/src/App.tsx | 2 +- ui/src/api/issues.ts | 2 + ui/src/api/plugins.test.ts | 64 + ui/src/api/plugins.ts | 91 + ui/src/components/CompanySettingsSidebar.tsx | 8 +- ui/src/components/FileTree.test.tsx | 190 + ui/src/components/FileTree.tsx | 500 + ui/src/components/InstanceSidebar.tsx | 14 +- ui/src/components/Layout.test.tsx | 284 +- ui/src/components/Layout.tsx | 72 +- ui/src/components/ManagedRoutinesList.tsx | 180 + ui/src/components/MarkdownBody.test.tsx | 68 +- ui/src/components/MarkdownBody.tsx | 183 +- ui/src/components/PackageFileTree.tsx | 344 +- .../components/ResizableSidebarPane.test.tsx | 121 + ui/src/components/ResizableSidebarPane.tsx | 176 + ui/src/components/RoutineList.tsx | 196 + ui/src/components/Sidebar.tsx | 2 +- ui/src/lib/queryKeys.ts | 4 + ui/src/lib/status-colors.ts | 3 + ui/src/pages/AgentDetail.tsx | 4 +- ui/src/pages/CompanyExport.tsx | 7 +- ui/src/pages/CompanyImport.tsx | 7 +- ui/src/pages/PluginPage.test.tsx | 207 + ui/src/pages/PluginPage.tsx | 99 +- ui/src/pages/PluginSettings.test.tsx | 286 +- ui/src/pages/PluginSettings.tsx | 363 +- ui/src/pages/ProjectDetail.test.tsx | 187 + ui/src/pages/ProjectDetail.tsx | 88 +- ui/src/pages/RoutineDetail.tsx | 66 +- ui/src/pages/Routines.tsx | 137 +- ui/src/plugins/bridge-init.ts | 506 + ui/src/plugins/bridge.test.ts | 306 + ui/src/plugins/bridge.ts | 185 +- ui/src/plugins/slots.tsx | 53 +- 89 files changed, 27516 insertions(+), 914 deletions(-) create mode 100644 packages/db/src/migrations/0076_useful_elektra.sql create mode 100644 packages/db/src/migrations/meta/0076_snapshot.json create mode 100644 packages/db/src/schema/plugin_managed_resources.ts create mode 100644 packages/shared/src/validators/plugin.test.ts create mode 100644 server/src/__tests__/plugin-local-folders.test.ts create mode 100644 server/src/__tests__/plugin-managed-agents.test.ts create mode 100644 server/src/__tests__/plugin-managed-routines.test.ts create mode 100644 server/src/services/plugin-local-folders.ts create mode 100644 server/src/services/plugin-managed-agents.ts create mode 100644 server/src/services/plugin-managed-routines.ts create mode 100644 ui/src/api/plugins.test.ts create mode 100644 ui/src/components/FileTree.test.tsx create mode 100644 ui/src/components/FileTree.tsx create mode 100644 ui/src/components/ManagedRoutinesList.tsx create mode 100644 ui/src/components/ResizableSidebarPane.test.tsx create mode 100644 ui/src/components/ResizableSidebarPane.tsx create mode 100644 ui/src/components/RoutineList.tsx create mode 100644 ui/src/pages/PluginPage.test.tsx create mode 100644 ui/src/pages/ProjectDetail.test.tsx create mode 100644 ui/src/plugins/bridge.test.ts diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index e3c243783a6..01edfcffbd7 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -13,7 +13,9 @@ It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec i - Plugin database migrations are restricted to a host-derived plugin namespace. - Plugin-owned JSON API routes must be declared in the manifest and are mounted only under `/api/plugins/:pluginId/api/*`. -- There is no host-provided shared React component kit for plugins yet. +- The host provides a small shared React component kit through + `@paperclipai/plugin-sdk/ui`; use it for common Paperclip controls before + building custom versions. - `ctx.assets` is not supported in the current runtime. ## Scaffold a plugin @@ -168,6 +170,187 @@ Mount surfaces currently wired in the host include: - `commentAnnotation` - `commentContextMenuItem` +## Shared host components + +Use shared components from `@paperclipai/plugin-sdk/ui` when the plugin needs a +Paperclip-native control. The host owns the implementation, so plugins inherit +the board's current styling, ordering, recent selections, and dark-mode behavior +without importing `ui/src` internals. + +Currently exposed components include: + +- `MarkdownBlock` and `MarkdownEditor` for rendered and editable markdown. +- `FileTree` for serializable file and directory trees. +- `IssuesList` for a native company-scoped issue table. +- `AssigneePicker` for the same agent/user selector used in the new issue pane. + Use the controlled `value` format `agent:`, `user:`, or `""`. +- `ProjectPicker` for the same project selector used in the new issue pane. + Use the controlled project id value, or `""` for no project. +- `ManagedRoutinesList` for plugin-owned routine settings pages. + +```tsx +import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui"; + +export function PluginAssignmentControls({ companyId }: { companyId: string }) { + const [assignee, setAssignee] = useState(""); + const [projectId, setProjectId] = useState(""); + + return ( + <> + setAssignee(value)} + /> + + + ); +} +``` + +## File and path UI + +Plugin UI often needs to render a file tree, accept a folder path, or browse a +project workspace. There are three different surfaces for that, and they map to +different trust and data-flow boundaries. Pick the surface that matches the +data the plugin actually has. + +### When to use the shared `FileTree` + +Use `FileTree` from `@paperclipai/plugin-sdk/ui` whenever the plugin only needs +to render a serializable file/directory list and react to selection or +expand/collapse. The host owns the implementation, so plugin UI inherits the +board's icons, indent, focus ring, and dark-mode styling without importing host +internals. + +```tsx +import { + FileTree, + type FileTreeNode, +} from "@paperclipai/plugin-sdk/ui"; + +const nodes: FileTreeNode[] = [ + { name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] }, + { + name: "wiki", + path: "wiki", + kind: "dir", + children: [ + { name: "index.md", path: "wiki/index.md", kind: "file", children: [] }, + ], + }, +]; + +export function WikiTree() { + const [expanded, setExpanded] = useState>(() => new Set(["wiki"])); + const [selected, setSelected] = useState(null); + + return ( + setSelected(path)} + onToggleDir={(path) => + setExpanded((current) => { + const next = new Set(current); + next.has(path) ? next.delete(path) : next.add(path); + return next; + }) + } + /> + ); +} +``` + +Good fits: + +- LLM Wiki page navigation in `packages/plugins/plugin-llm-wiki` builds a + `FileTreeNode[]` from worker query results and renders it through `FileTree`. +- The example `plugin-file-browser-example` lazily fetches a directory's + children through a `loadFileList` action when `onToggleDir` fires, then + merges the children into the local tree state — letting the shared component + handle rendering and selection. + +Boundary rules: + +- Keep the prop surface serializable (`nodes`, `expandedPaths`, `checkedPaths`, + `fileBadges`, `fileTones`). Do not pass arbitrary render functions across the + plugin/host boundary in v1; the supported escape hatches are + `fileBadges` (status pill keyed by path) and `fileTones` (row tone keyed by + path). +- Do not import the host's `FileTree.tsx` or any `ui/src/*` module. The SDK + declaration is the only supported import path for plugin UI. +- The shared `FileTree` is for rendering and selection. Plugin-specific editors, + ingest flows, query forms, and lint runs stay inside the plugin and do not + belong as `FileTree` props. + +### When to declare `localFolders` + +When the plugin needs operator-configured filesystem roots — typically for +trusted local plugins like wiki tooling — declare `localFolders[]` on the +manifest and add the `local.folders` capability. The host renders a settings +surface for the operator to set the absolute path, validates the path +server-side (containment, symlinks, required files/directories), and exposes +`ctx.localFolders.readText()` and `ctx.localFolders.writeTextAtomic()` in the +worker. + +```ts +export const manifest = { + capabilities: ["local.folders"], + localFolders: [ + { + folderKey: "content-root", + displayName: "Content root", + access: "readWrite", + requiredDirectories: ["sources", "pages"], + requiredFiles: ["schema.md"], + }, + ], +}; +``` + +Use this when: + +- The data lives outside any project workspace. +- Reads and writes need company-scoped configuration. +- The operator picks the path once in plugin settings and the worker resolves + files relative to that root. + +Do not use `localFolders` to grant the UI direct browser-side access to the +filesystem — there is no such capability. The browser still goes through the +worker via `getData` / `performAction`, and the worker only exposes paths it +chose to expose. + +### When to keep worker-mediated project workspace browsing + +When the data lives inside an existing project workspace, keep the browsing +flow worker-mediated: + +- The worker uses `ctx.projects.listWorkspaces()` to resolve the workspace + path, then reads its filesystem with normal Node APIs. +- The plugin UI calls a `getData` handler for the root listing and an action + for lazy children, then renders them through `FileTree`. +- The worker is the only side that touches the disk. The browser receives a + serializable tree and never sees raw absolute paths it can replay. + +The example `plugin-file-browser-example` is the reference for this pattern: +the worker registers `fileList` (data) and `loadFileList` (action) over the +same handler, and the UI uses the action for on-toggle directory loading so the +shared `FileTree` stays the rendering surface. + +### Mixing surfaces + +A single plugin can use more than one of these. The LLM Wiki uses +`localFolders` for its content root, then renders the resulting page list +through `FileTree`. The file browser example uses `ctx.projects.listWorkspaces` +to pick a workspace and renders its on-disk tree through `FileTree` with lazy +loading. Pick the boundary per data source, not per plugin. + ## Company routes Plugins may declare a `page` slot with `routePath` to own a company route like: diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index ed8dea6b2c0..31b9c9d0d75 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -27,7 +27,7 @@ Current limitations to keep in mind: - Published npm packages are the intended install artifact for deployed plugins. - The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build. - Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet. -- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises. +- The current runtime ships a small host-provided plugin UI component kit through `@paperclipai/plugin-sdk/ui`, but does not support plugin asset uploads/reads yet. Treat plugin asset APIs as future-scope ideas, not current implementation promises. - Scoped plugin API routes are JSON-only and must be declared in `apiRoutes`. They mount under `/api/plugins/:pluginId/api/*`; plugins cannot shadow core API routes. @@ -976,13 +976,23 @@ export function DashboardWidget({ context }: PluginWidgetProps) { The SDK includes a `ui` subpath export that plugin frontends import. This subpath provides: -- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()` +- **Bridge hooks**: `usePluginData(key, params)`, `usePluginAction(key)`, `useHostContext()`, `useHostNavigation()` - **Design tokens**: colors, spacing, typography, shadows matching the host theme - **Shared components**: `MetricCard`, `StatusBadge`, `DataTable`, `LogView`, `ActionBar`, `Spinner`, etc. - **Type definitions**: `PluginPageProps`, `PluginWidgetProps`, `PluginDetailTabProps` Plugins are encouraged but not required to use the shared components. A plugin may render entirely custom UI as long as it communicates through the bridge. +`useHostNavigation()` is the supported way for plugin UI to navigate to +Paperclip-internal pages. It exposes `resolveHref(to)`, `navigate(to, +options?)`, and `linkProps(to, options?)`. Plugin links should prefer +`linkProps()` so anchors keep real `href` values for copy-link, modifier-click, +middle-click, and open-in-new-tab behavior while plain left-clicks route through +the host SPA router. The host resolves company-scoped paths against the active +company prefix without double-prefixing already-prefixed paths. Plugin UI should +not use raw same-origin `href`s or `window.location.assign()` for internal +Paperclip navigation because those can force a full document reload. + ### 19.0.2 Bundle Isolation Plugin UI bundles are loaded as standard ES modules, not iframed. This gives plugins full rendering performance and access to the host's design tokens. @@ -1062,6 +1072,11 @@ The host SDK ships shared components that plugins can import to quickly build UI | `LogView` | Scrollable log output with timestamps | Webhook deliveries, job output, process logs | | `JsonTree` | Collapsible JSON tree for debugging | Raw API responses, plugin state inspection | | `Spinner` | Loading indicator | Data fetch states | +| `FileTree` | Host-styled file/directory tree | Wiki pages, workspace files, import previews | +| `IssuesList` | Host issue list | Plugin pages that need a native issue view | +| `AssigneePicker` | Host assignee picker for agents and board users | Creating issues, assigning routines, filtering work | +| `ProjectPicker` | Host project picker | Creating issues, scoping dashboards, filtering work | +| `ManagedRoutinesList` | Host routine list | Plugin settings pages that manage routines | Plugins may also use entirely custom components. The shared components exist to reduce boilerplate and keep visual consistency, not to limit what plugins can render. diff --git a/packages/db/src/migrations/0076_useful_elektra.sql b/packages/db/src/migrations/0076_useful_elektra.sql new file mode 100644 index 00000000000..3620f16917d --- /dev/null +++ b/packages/db/src/migrations/0076_useful_elektra.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS "plugin_managed_resources" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "plugin_id" uuid NOT NULL, + "plugin_key" text NOT NULL, + "resource_kind" text NOT NULL, + "resource_key" text NOT NULL, + "resource_id" uuid NOT NULL, + "defaults_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "plugin_managed_resources" ADD CONSTRAINT "plugin_managed_resources_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plugin_managed_resources_company_idx" ON "plugin_managed_resources" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plugin_managed_resources_plugin_idx" ON "plugin_managed_resources" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "plugin_managed_resources_resource_idx" ON "plugin_managed_resources" USING btree ("resource_kind","resource_id");--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "plugin_managed_resources_company_plugin_resource_uq" ON "plugin_managed_resources" USING btree ("company_id","plugin_id","resource_kind","resource_key"); diff --git a/packages/db/src/migrations/meta/0076_snapshot.json b/packages/db/src/migrations/meta/0076_snapshot.json new file mode 100644 index 00000000000..d002c177609 --- /dev/null +++ b/packages/db/src/migrations/meta/0076_snapshot.json @@ -0,0 +1,16134 @@ +{ + "id": "063c8887-ed46-4125-a08f-51c16b636245", + "prevId": "fdc9cd8b-5423-4d64-b255-9bc1497fdd6a", + "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 + }, + "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_run_id_idx": { + "name": "activity_log_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "activity_log_entity_type_id_idx": { + "name": "activity_log_entity_type_id_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "activity_log_company_id_companies_id_fk": { + "name": "activity_log_company_id_companies_id_fk", + "tableFrom": "activity_log", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_agent_id_agents_id_fk": { + "name": "activity_log_agent_id_agents_id_fk", + "tableFrom": "activity_log", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_log_run_id_heartbeat_runs_id_fk": { + "name": "activity_log_run_id_heartbeat_runs_id_fk", + "tableFrom": "activity_log", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_api_keys": { + "name": "agent_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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_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": {} + } + }, + "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 + }, + "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.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 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feedback_data_sharing_enabled": { + "name": "feedback_data_sharing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feedback_data_sharing_consent_at": { + "name": "feedback_data_sharing_consent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_consent_by_user_id": { + "name": "feedback_data_sharing_consent_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_terms_version": { + "name": "feedback_data_sharing_terms_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_logos": { + "name": "company_logos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_logos_company_uq": { + "name": "company_logos_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_logos_asset_uq": { + "name": "company_logos_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_logos_company_id_companies_id_fk": { + "name": "company_logos_company_id_companies_id_fk", + "tableFrom": "company_logos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_logos_asset_id_assets_id_fk": { + "name": "company_logos_asset_id_assets_id_fk", + "tableFrom": "company_logos", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_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 + }, + "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_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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "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 + }, + "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_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_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, + "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_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": {}, + "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 + }, + "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" + }, + "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": {} + } + }, + "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" + } + }, + "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'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_provider_occurred_idx": { + "name": "cost_events_company_provider_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_biller_occurred_idx": { + "name": "cost_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_heartbeat_run_idx": { + "name": "cost_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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": "no action", + "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 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time 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": {} + } + }, + "foreignKeys": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environment_leases": { + "name": "environment_leases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "environment_id": { + "name": "environment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "lease_policy": { + "name": "lease_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ephemeral'" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_lease_id": { + "name": "provider_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_status": { + "name": "cleanup_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "environment_leases_company_environment_status_idx": { + "name": "environment_leases_company_environment_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "environment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_execution_workspace_idx": { + "name": "environment_leases_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_issue_idx": { + "name": "environment_leases_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_heartbeat_run_idx": { + "name": "environment_leases_heartbeat_run_idx", + "columns": [ + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_company_last_used_idx": { + "name": "environment_leases_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "environment_leases_provider_lease_idx": { + "name": "environment_leases_provider_lease_idx", + "columns": [ + { + "expression": "provider_lease_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "environment_leases_company_id_companies_id_fk": { + "name": "environment_leases_company_id_companies_id_fk", + "tableFrom": "environment_leases", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_leases_environment_id_environments_id_fk": { + "name": "environment_leases_environment_id_environments_id_fk", + "tableFrom": "environment_leases", + "tableTo": "environments", + "columnsFrom": [ + "environment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "environment_leases_execution_workspace_id_execution_workspaces_id_fk": { + "name": "environment_leases_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "environment_leases", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_leases_issue_id_issues_id_fk": { + "name": "environment_leases_issue_id_issues_id_fk", + "tableFrom": "environment_leases", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "environment_leases_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "environment_leases", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.environments": { + "name": "environments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "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 + }, + "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" + }, + "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_company_status_idx": { + "name": "environments_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": {} + }, + "environments_company_driver_idx": { + "name": "environments_company_driver_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "driver", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"environments\".\"driver\" = 'local'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "environments_company_name_idx": { + "name": "environments_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": {} + } + }, + "foreignKeys": { + "environments_company_id_companies_id_fk": { + "name": "environments_company_id_companies_id_fk", + "tableFrom": "environments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_workspaces": { + "name": "execution_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy_type": { + "name": "strategy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_fs'" + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "derived_from_execution_workspace_id": { + "name": "derived_from_execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_eligible_at": { + "name": "cleanup_eligible_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_workspaces_company_project_status_idx": { + "name": "execution_workspaces_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_project_workspace_status_idx": { + "name": "execution_workspaces_company_project_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_source_issue_idx": { + "name": "execution_workspaces_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_last_used_idx": { + "name": "execution_workspaces_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_branch_idx": { + "name": "execution_workspaces_company_branch_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspaces_company_id_companies_id_fk": { + "name": "execution_workspaces_company_id_companies_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "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.feedback_exports": { + "name": "feedback_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_vote_id": { + "name": "feedback_vote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_only'" + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "export_id": { + "name": "export_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-envelope-v2'" + }, + "bundle_version": { + "name": "bundle_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-bundle-v2'" + }, + "payload_version": { + "name": "payload_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-v1'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_snapshot": { + "name": "payload_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_summary": { + "name": "target_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_exports_feedback_vote_idx": { + "name": "feedback_exports_feedback_vote_idx", + "columns": [ + { + "expression": "feedback_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_created_idx": { + "name": "feedback_exports_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_status_idx": { + "name": "feedback_exports_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_issue_idx": { + "name": "feedback_exports_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_project_idx": { + "name": "feedback_exports_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_author_idx": { + "name": "feedback_exports_company_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_exports_company_id_companies_id_fk": { + "name": "feedback_exports_company_id_companies_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_exports_feedback_vote_id_feedback_votes_id_fk": { + "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "feedback_votes", + "columnsFrom": [ + "feedback_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_issue_id_issues_id_fk": { + "name": "feedback_exports_issue_id_issues_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_project_id_projects_id_fk": { + "name": "feedback_exports_project_id_projects_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_votes": { + "name": "feedback_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_with_labs": { + "name": "shared_with_labs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_votes_company_issue_idx": { + "name": "feedback_votes_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_issue_target_idx": { + "name": "feedback_votes_issue_target_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_author_idx": { + "name": "feedback_votes_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_company_target_author_idx": { + "name": "feedback_votes_company_target_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_votes_company_id_companies_id_fk": { + "name": "feedback_votes_company_id_companies_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_votes_issue_id_issues_id_fk": { + "name": "feedback_votes_issue_id_issues_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_events": { + "name": "finance_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_kind": { + "name": "event_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'debit'" + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_adapter_type": { + "name": "execution_adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing_tier": { + "name": "pricing_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_invoice_id": { + "name": "external_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "finance_events_company_occurred_idx": { + "name": "finance_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_biller_occurred_idx": { + "name": "finance_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_kind_occurred_idx": { + "name": "finance_events_company_kind_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_direction_occurred_idx": { + "name": "finance_events_company_direction_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_heartbeat_run_idx": { + "name": "finance_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_cost_event_idx": { + "name": "finance_events_company_cost_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_events_company_id_companies_id_fk": { + "name": "finance_events_company_id_companies_id_fk", + "tableFrom": "finance_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_agent_id_agents_id_fk": { + "name": "finance_events_agent_id_agents_id_fk", + "tableFrom": "finance_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_issue_id_issues_id_fk": { + "name": "finance_events_issue_id_issues_id_fk", + "tableFrom": "finance_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_project_id_projects_id_fk": { + "name": "finance_events_project_id_projects_id_fk", + "tableFrom": "finance_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_goal_id_goals_id_fk": { + "name": "finance_events_goal_id_goals_id_fk", + "tableFrom": "finance_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_cost_event_id_cost_events_id_fk": { + "name": "finance_events_cost_event_id_cost_events_id_fk", + "tableFrom": "finance_events", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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'" + }, + "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_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": {} + } + }, + "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 + }, + "dismissed_at": { + "name": "dismissed_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": { + "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.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'" + }, + "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": {}, + "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 + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "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": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_body_search_idx": { + "name": "issue_comments_body_search_idx", + "columns": [ + { + "expression": "body", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_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" + } + }, + "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_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_inbox_archives_company_issue_idx": { + "name": "issue_inbox_archives_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_user_idx": { + "name": "issue_inbox_archives_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_issue_user_idx": { + "name": "issue_inbox_archives_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_inbox_archives_company_id_companies_id_fk": { + "name": "issue_inbox_archives_company_id_companies_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_inbox_archives_issue_id_issues_id_fk": { + "name": "issue_inbox_archives_issue_id_issues_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_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'" + }, + "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 + }, + "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 + }, + "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": {} + } + }, + "foreignKeys": { + "issue_thread_interactions_company_id_companies_id_fk": { + "name": "issue_thread_interactions_company_id_companies_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_issue_id_issues_id_fk": { + "name": "issue_thread_interactions_issue_id_issues_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_source_comment_id_issue_comments_id_fk": { + "name": "issue_thread_interactions_source_comment_id_issue_comments_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "issue_comments", + "columnsFrom": [ + "source_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk": { + "name": "issue_thread_interactions_source_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "source_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_thread_interactions_created_by_agent_id_agents_id_fk": { + "name": "issue_thread_interactions_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_thread_interactions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_thread_interactions_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" + } + }, + "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_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 + }, + "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'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "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 + }, + "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 + }, + "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_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_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_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_productivity_review_uq": { + "name": "issues_active_productivity_review_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'issue_productivity_review'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_active_stranded_issue_recovery_uq": { + "name": "issues_active_stranded_issue_recovery_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'stranded_issue_recovery'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"status\" not in ('done', 'cancelled')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_workspace_id_project_workspaces_id_fk": { + "name": "issues_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issues_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_user_uq": { + "name": "join_requests_pending_human_user_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requesting_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"requesting_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_pending_human_email_uq": { + "name": "join_requests_pending_human_email_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"request_email_snapshot\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"join_requests\".\"request_type\" = 'human' AND \"join_requests\".\"status\" = 'pending_approval' AND \"join_requests\".\"request_email_snapshot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "join_requests_invite_id_invites_id_fk": { + "name": "join_requests_invite_id_invites_id_fk", + "tableFrom": "join_requests", + "tableTo": "invites", + "columnsFrom": [ + "invite_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_company_id_companies_id_fk": { + "name": "join_requests_company_id_companies_id_fk", + "tableFrom": "join_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "join_requests_created_agent_id_agents_id_fk": { + "name": "join_requests_created_agent_id_agents_id_fk", + "tableFrom": "join_requests", + "tableTo": "agents", + "columnsFrom": [ + "created_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.labels": { + "name": "labels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "labels_company_idx": { + "name": "labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "labels_company_name_idx": { + "name": "labels_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "labels_company_id_companies_id_fk": { + "name": "labels_company_id_companies_id_fk", + "tableFrom": "labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 + }, + "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_id_idx": { + "name": "plugin_config_plugin_id_idx", + "columns": [ + { + "expression": "plugin_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" + } + }, + "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 + }, + "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_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": {} + }, + "plugin_entities_external_idx": { + "name": "plugin_entities_external_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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 + }, + "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_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" + } + }, + "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 + }, + "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_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" + } + }, + "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 + }, + "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_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" + } + }, + "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_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 + }, + "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_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()" + }, + "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_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_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 + }, + "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'" + }, + "variables": { + "name": "variables", + "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 + }, + "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": {} + } + }, + "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_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.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 + }, + "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": {} + } + }, + "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": "no action", + "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" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_execution_workspace_status_idx": { + "name": "workspace_runtime_services_company_execution_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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 + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index f0d21b99889..0c82238a6ee 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -533,6 +533,13 @@ "when": 1777572332006, "tag": "0075_cultured_sebastian_shaw", "breakpoints": true + }, + { + "idx": 76, + "version": "7", + "when": 1777675301279, + "tag": "0076_useful_elektra", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 322a326dfc0..7e4a0eb1ba7 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -65,6 +65,7 @@ export { companySkills } from "./company_skills.js"; export { plugins } from "./plugins.js"; export { pluginConfig } from "./plugin_config.js"; export { pluginCompanySettings } from "./plugin_company_settings.js"; +export { pluginManagedResources } from "./plugin_managed_resources.js"; export { pluginState } from "./plugin_state.js"; export { pluginEntities } from "./plugin_entities.js"; export { pluginDatabaseNamespaces, pluginMigrations } from "./plugin_database.js"; diff --git a/packages/db/src/schema/plugin_managed_resources.ts b/packages/db/src/schema/plugin_managed_resources.ts new file mode 100644 index 00000000000..ec254d14961 --- /dev/null +++ b/packages/db/src/schema/plugin_managed_resources.ts @@ -0,0 +1,34 @@ +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { plugins } from "./plugins.js"; + +export const pluginManagedResources = pgTable( + "plugin_managed_resources", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + pluginKey: text("plugin_key").notNull(), + resourceKind: text("resource_kind").notNull(), + resourceKey: text("resource_key").notNull(), + resourceId: uuid("resource_id").notNull(), + defaultsJson: jsonb("defaults_json").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("plugin_managed_resources_company_idx").on(table.companyId), + pluginIdx: index("plugin_managed_resources_plugin_idx").on(table.pluginId), + resourceIdx: index("plugin_managed_resources_resource_idx").on(table.resourceKind, table.resourceId), + companyPluginResourceUq: uniqueIndex("plugin_managed_resources_company_plugin_resource_uq").on( + table.companyId, + table.pluginId, + table.resourceKind, + table.resourceKey, + ), + }), +); diff --git a/packages/plugins/create-paperclip-plugin/README.md b/packages/plugins/create-paperclip-plugin/README.md index 2429412267a..967fe56a91c 100644 --- a/packages/plugins/create-paperclip-plugin/README.md +++ b/packages/plugins/create-paperclip-plugin/README.md @@ -27,7 +27,7 @@ Generates: - `esbuild` and `rollup` config files using SDK bundler presets - dev server script for hot-reload (`paperclip-plugin-dev-server`) -The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet. +The scaffold starts with plain React elements so the generated plugin stays minimal. For Paperclip-native controls, import shared host components such as `MarkdownEditor`, `FileTree`, `AssigneePicker`, and `ProjectPicker` from `@paperclipai/plugin-sdk/ui`. Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`. diff --git a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx index 0e12d903dcf..e7088fbc8b6 100644 --- a/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx @@ -1,11 +1,12 @@ import type { + FileTreeNode, PluginProjectSidebarItemProps, PluginDetailTabProps, PluginCommentAnnotationProps, PluginCommentContextMenuItemProps, } from "@paperclipai/plugin-sdk/ui"; -import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui"; -import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react"; +import { FileTree, usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui"; +import { useCallback, useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react"; import { EditorView } from "@codemirror/view"; import { basicSetup } from "codemirror"; import { javascript } from "@codemirror/lang-javascript"; @@ -129,15 +130,31 @@ const editorLightHighlightStyle = HighlightStyle.define([ type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean }; type FileEntry = { name: string; path: string; isDirectory: boolean }; -type FileTreeNodeProps = { - entry: FileEntry; - companyId: string | null; - projectId: string; - workspaceId: string; - selectedPath: string | null; - onSelect: (path: string) => void; - depth?: number; -}; + +function entryToFileTreeNode(entry: FileEntry): FileTreeNode { + return { + name: entry.name, + path: entry.path, + kind: entry.isDirectory ? "dir" : "file", + children: [], + }; +} + +function entriesToFileTreeNodes(entries: FileEntry[]): FileTreeNode[] { + return entries.map(entryToFileTreeNode); +} + +function setChildrenAtPath(nodes: FileTreeNode[], path: string, children: FileTreeNode[]): FileTreeNode[] { + return nodes.map((node) => { + if (node.path === path) { + return { ...node, children }; + } + if (node.kind === "dir" && node.children.length > 0 && (path === node.path || path.startsWith(`${node.path}/`))) { + return { ...node, children: setChildrenAtPath(node.children, path, children) }; + } + return node; + }); +} const PathLikePattern = /[\\/]/; const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/; @@ -235,109 +252,6 @@ function useAvailableHeight( return height; } -function FileTreeNode({ - entry, - companyId, - projectId, - workspaceId, - selectedPath, - onSelect, - depth = 0, -}: FileTreeNodeProps) { - const [isExpanded, setIsExpanded] = useState(false); - const isSelected = selectedPath === entry.path; - - if (entry.isDirectory) { - return ( -
  • - - {isExpanded ? ( - - ) : null} -
  • - ); - } - - return ( -
  • - -
  • - ); -} - -function ExpandedDirectoryChildren({ - directoryPath, - companyId, - projectId, - workspaceId, - selectedPath, - onSelect, - depth, -}: { - directoryPath: string; - companyId: string | null; - projectId: string; - workspaceId: string; - selectedPath: string | null; - onSelect: (path: string) => void; - depth: number; -}) { - const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", { - companyId, - projectId, - workspaceId, - directoryPath, - }); - const children = childData?.entries ?? []; - - if (children.length === 0) { - return null; - } - - return ( -
      - {children.map((child) => ( - - ))} -
    - ); -} - /** * Project sidebar item: link "Files" that opens the project detail with the Files plugin tab. */ @@ -430,11 +344,60 @@ export function FilesTab({ context }: PluginDetailTabProps) { () => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}), [companyId, projectId, selectedWorkspace], ); - const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>( + const { data: fileListData, loading: fileListLoading, error: fileListError } = usePluginData<{ entries: FileEntry[] }>( "fileList", fileListParams, ); - const entries = fileListData?.entries ?? []; + + // Lazy-load directory children through an imperative action so the shared + // FileTree can reuse `expandedPaths` for state without spawning a hook per + // expanded directory. + const loadFileList = usePluginAction("loadFileList"); + const [nodes, setNodes] = useState([]); + const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); + const [loadedDirs, setLoadedDirs] = useState>(() => new Set()); + const [loadingDirs, setLoadingDirs] = useState>(() => new Set()); + + useEffect(() => { + setNodes(fileListData?.entries ? entriesToFileTreeNodes(fileListData.entries) : []); + setExpandedPaths(new Set()); + setLoadedDirs(new Set()); + setLoadingDirs(new Set()); + }, [fileListData, selectedWorkspace?.id]); + + const handleToggleDir = useCallback( + (dirPath: string) => { + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(dirPath)) next.delete(dirPath); + else next.add(dirPath); + return next; + }); + if (!selectedWorkspace) return; + if (loadedDirs.has(dirPath) || loadingDirs.has(dirPath)) return; + setLoadingDirs((current) => new Set(current).add(dirPath)); + void loadFileList({ + projectId, + companyId, + workspaceId: selectedWorkspace.id, + directoryPath: dirPath, + }) + .then((response) => { + const entries = (response as { entries?: FileEntry[] })?.entries ?? []; + const children = entriesToFileTreeNodes(entries); + setNodes((current) => setChildrenAtPath(current, dirPath, children)); + setLoadedDirs((current) => new Set(current).add(dirPath)); + }) + .finally(() => { + setLoadingDirs((current) => { + const next = new Set(current); + next.delete(dirPath); + return next; + }); + }); + }, + [companyId, loadFileList, loadedDirs, loadingDirs, projectId, selectedWorkspace], + ); // Track the `?file=` query parameter across navigations (popstate). const [urlFilePath, setUrlFilePath] = useState(() => { @@ -610,28 +573,23 @@ export function FilesTab({ context }: PluginDetailTabProps) {
    {selectedWorkspace ? ( - fileListLoading ? ( -

    Loading files...

    - ) : entries.length > 0 ? ( -
      - {entries.map((entry) => ( - { - setSelectedPath(path); - setMobileView("editor"); - }} - /> - ))} -
    - ) : ( -

    No files found in this workspace.

    - ) + { + setSelectedPath(path); + setMobileView("editor"); + }} + loading={fileListLoading} + error={fileListError ? { message: fileListError.message } : null} + empty={{ + title: "No files", + description: "No files found in this workspace.", + }} + ariaLabel="Workspace files" + /> ) : (

    Select a workspace to browse files.

    )} diff --git a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts index a168983406f..cf038cf0173 100644 --- a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts +++ b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts @@ -106,43 +106,46 @@ const plugin = definePlugin({ })); }); - ctx.data.register( - "fileList", - async (params: Record) => { - const projectId = params.projectId as string; - const companyId = typeof params.companyId === "string" ? params.companyId : ""; - const workspaceId = params.workspaceId as string; - const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : ""; - if (!projectId || !companyId || !workspaceId) return { entries: [] }; - const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); - const workspace = workspaces.find((w) => w.id === workspaceId); - if (!workspace) return { entries: [] }; - const workspacePath = sanitizeWorkspacePath(workspace.path); - if (!workspacePath) return { entries: [] }; - const dirPath = resolveWorkspace(workspacePath, directoryPath); - if (!dirPath) { - return { entries: [] }; - } - if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { - return { entries: [] }; - } - const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b)); - const entries = names.map((name) => { - const full = path.join(dirPath, name); - const stat = fs.lstatSync(full); - const relativePath = path.relative(workspacePath, full); - return { - name, - path: relativePath, - isDirectory: stat.isDirectory(), - }; - }).sort((a, b) => { - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; - return a.name.localeCompare(b.name); - }); - return { entries }; - }, - ); + async function readFileList(params: Record) { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + const workspaceId = params.workspaceId as string; + const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : ""; + if (!projectId || !companyId || !workspaceId) return { entries: [] }; + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((w) => w.id === workspaceId); + if (!workspace) return { entries: [] }; + const workspacePath = sanitizeWorkspacePath(workspace.path); + if (!workspacePath) return { entries: [] }; + const dirPath = resolveWorkspace(workspacePath, directoryPath); + if (!dirPath) { + return { entries: [] }; + } + if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) { + return { entries: [] }; + } + const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b)); + const entries = names.map((name) => { + const full = path.join(dirPath, name); + const stat = fs.lstatSync(full); + const relativePath = path.relative(workspacePath, full); + return { + name, + path: relativePath, + isDirectory: stat.isDirectory(), + }; + }).sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { entries }; + } + + ctx.data.register("fileList", readFileList); + + // Mirror `fileList` as an action so the UI can lazily fetch directory + // children on tree expand without spawning a usePluginData hook per dir. + ctx.actions.register("loadFileList", readFileList); ctx.data.register( "fileContent", diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx index 826dd83258d..ea3cb491e31 100644 --- a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react"; import { + AssigneePicker, + ProjectPicker, useHostContext, + useHostNavigation, usePluginAction, usePluginData, usePluginStream, @@ -248,14 +251,6 @@ const mutedTextStyle: CSSProperties = { lineHeight: 1.45, }; -function hostPath(companyPrefix: string | null | undefined, suffix: string): string { - return companyPrefix ? `/${companyPrefix}${suffix}` : suffix; -} - -function pluginPagePath(companyPrefix: string | null | undefined): string { - return hostPath(companyPrefix, `/${PAGE_ROUTE}`); -} - function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -521,6 +516,7 @@ function CompactSurfaceSummary({ label, entityType }: { label: string; entityTyp function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) { const overview = usePluginOverview(context.companyId); const toast = usePluginToast(); + const hostNavigation = useHostNavigation(); const emitDemoEvent = usePluginAction("emit-demo-event"); const startProgressStream = usePluginAction("start-progress-stream"); const writeMetric = usePluginAction("write-metric"); @@ -591,7 +587,7 @@ function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context tone: "info", action: { label: "Go", - href: hostPath(context.companyPrefix, "/dashboard"), + href: hostNavigation.resolveHref("/dashboard"), }, })} > @@ -1079,6 +1075,7 @@ function KitchenSinkCompanyCrudDemo({ context }: { context: PluginPageProps["con } function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) { + const hostNavigation = useHostNavigation(); return (
    The company sidebar entry opens this route directly, so the plugin feels like a first-class company page instead of a settings subpage.
    - - {pluginPagePath(context.companyPrefix)} + + {hostNavigation.resolveHref(`/${PAGE_ROUTE}`)}
    @@ -1193,6 +1190,7 @@ function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context } function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) { + const hostNavigation = useHostNavigation(); const [liveRuns, setLiveRuns] = useState([]); const [recentRuns, setRecentRuns] = useState([]); const [loading, setLoading] = useState(false); @@ -1228,7 +1226,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
    Company Route - +
    This page is mounted as a real company route instead of living only under `/plugins/:pluginId`. @@ -1260,7 +1258,7 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[
    {run.id}
    {run.agentId ? ( - + Open run ) : null} @@ -1294,6 +1292,44 @@ function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps[ ); } +function KitchenSinkSharedPickerDemo({ context }: { context: PluginPageProps["context"] }) { + const [assigneeValue, setAssigneeValue] = useState(""); + const [projectId, setProjectId] = useState(context.projectId ?? ""); + + useEffect(() => { + setProjectId(context.projectId ?? ""); + }, [context.projectId]); + + return ( +
    +
    + These controls are imported from `@paperclipai/plugin-sdk/ui` and reuse the host's assignee and project pickers from the new issue pane. +
    + {!context.companyId ? ( +
    Select a company to load picker options.
    + ) : ( +
    +
    + setAssigneeValue(value)} + /> + +
    +
    + Selected assignee: {assigneeValue || "none"}, selected project: {projectId || "none"} +
    +
    + )} +
    + ); +} + function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) { return (
    @@ -1301,12 +1337,14 @@ function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context +
    ); } function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) { + const hostNavigation = useHostNavigation(); const companyId = context.companyId; const overview = usePluginOverview(companyId); const [companiesLimit, setCompaniesLimit] = useState(20); @@ -1531,10 +1569,10 @@ function KitchenSinkConsole({ context }: { context: { companyId: string | null;
    - Open page + Open page
    ); } export function KitchenSinkProjectSidebarItem({ context }: PluginProjectSidebarItemProps) { + const hostNavigation = useHostNavigation(); const config = usePluginConfigData(); if (config.data && config.data.showProjectSidebarItem === false) return null; return ( Kitchen Sink diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index faadc501572..c1f73d98ba2 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -15,7 +15,7 @@ Reference: `doc/plugins/PLUGIN_SPEC.md` | Import | Purpose | |--------|--------| | `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers | -| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types | +| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, `useHostNavigation`, slot prop types | | `@paperclipai/plugin-sdk/ui/hooks` | Hooks only | | `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces | | `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests | @@ -47,7 +47,7 @@ The SDK is stable enough for local development and first-party examples, but the - For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime. - The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation. - Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes. -- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS. +- The host ships a small shared React component kit through `@paperclipai/plugin-sdk/ui`. Use it for native Paperclip controls; custom React and CSS are still supported. - `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet. If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience. @@ -100,12 +100,14 @@ runWorker(plugin, import.meta.url); | `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. | | `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. | -**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest. +**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest. **Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details. **Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`. +**Trusted local folders:** Declare `manifest.localFolders[]` and the `local.folders` capability when a plugin needs an operator-configured company-scoped folder. Use `ctx.localFolders.configure()`, `status()`, `readText()`, and `writeTextAtomic()` instead of resolving arbitrary filesystem paths yourself. The host validates absolute roots, read/write access, required relative folders/files, traversal attempts, symlink escapes, and writes through temp-file-plus-rename atomic replacement. + ## Events Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`). @@ -201,12 +203,13 @@ Slots are mount points for plugin React components. Launchers are host-rendered ### Slot types / launcher placement zones -The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy: +Slot types describe where a component mounts. Most values also exist as launcher placement zones. | Slot type / placement zone | Scope | Entity types (when context-sensitive) | |----------------------------|-------|---------------------------------------| | `page` | Global | — | | `sidebar` | Global | — | +| `routeSidebar` | Global | — | | `sidebarPanel` | Global | — | | `settingsPage` | Global | — | | `dashboardWidget` | Global | — | @@ -233,6 +236,10 @@ A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plu Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability. +#### `routeSidebar` + +Replaces the normal company sidebar while the current route is a plugin page route with the same `routePath`. Use this for full-page plugin workspaces that need their own local navigation while keeping the company rail and account footer. Receives `PluginRouteSidebarProps` with `context.companyId` and `context.companyPrefix` set to the active company. Requires the `ui.sidebar.register` capability. + #### `sidebarPanel` Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability. @@ -338,6 +345,7 @@ Declare in `manifest.capabilities`. Grouped by scope: | | `http.outbound` | | | `secrets.read-ref` | | | `environment.drivers.register` | +| | `local.folders` | | **Agent** | `agent.tools.register` | | | `agents.invoke` | | | `agent.sessions.create` | @@ -372,6 +380,38 @@ only inside the plugin namespace. Runtime `ctx.db.query()` allows `SELECT` from `ctx.db.execute()` allows `INSERT`, `UPDATE`, and `DELETE` only against the plugin namespace. +### Trusted Local Folders + +Trusted local plugins can request operator-configured folders per company: + +```ts +export const manifest = { + // ... + capabilities: ["local.folders"], + localFolders: [ + { + folderKey: "content-root", + displayName: "Content root", + access: "readWrite", + requiredDirectories: ["sources", "pages"], + requiredFiles: ["schema.md"], + }, + ], +}; +``` + +The host stores the selected path in company-scoped plugin settings and exposes +readiness through: + +- `GET /api/plugins/:pluginId/companies/:companyId/local-folders` +- `GET /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status` +- `POST /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate` +- `PUT /api/plugins/:pluginId/companies/:companyId/local-folders/:folderKey` + +Worker code should access files through `ctx.localFolders.readText()` and +`ctx.localFolders.writeTextAtomic()`. Relative paths must stay inside the +configured root; symlinks that escape the root are rejected. + ### Scoped API Routes Manifest-declared `apiRoutes` expose JSON routes under @@ -599,6 +639,23 @@ export function IssueLinearLink({ context }: PluginDetailTabProps) { } ``` +#### `useHostNavigation()` + +Routes Paperclip-internal plugin links through the host router without a full document reload. Use `linkProps()` for anchors so the browser still gets a real `href` for copy-link, modifier-click, middle-click, and open-in-new-tab behavior. + +```tsx +import { useHostNavigation } from "@paperclipai/plugin-sdk/ui"; + +export function WikiSidebarLink() { + const hostNavigation = useHostNavigation(); + return Wiki; +} +``` + +`linkProps("/wiki")` resolves against the active company prefix, so in company `PAP` it renders `href="/PAP/wiki"`. Already-prefixed paths such as `/PAP/wiki` are not prefixed again. For button-style commands, call `hostNavigation.navigate("/issues/PAP-123")`. + +Avoid raw same-origin `href`s or `window.location.assign()` for Paperclip-internal navigation from plugin UI. Those bypass the host router and can reload the whole app. External links should keep normal anchors with `target="_blank"` and `rel="noopener noreferrer"` as appropriate. + #### `usePluginStream(channel, options?)` Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`. @@ -629,7 +686,118 @@ The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?co ### UI authoring note -The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package. +The host provides selected shared UI components through `@paperclipai/plugin-sdk/ui`. +Plugins can also use normal React components, their own CSS, or small design +primitives inside the plugin package. + +Use the shared components when the plugin needs to look and behave like a native +Paperclip surface: + +| Component | Use when | +|---|---| +| `MarkdownBlock` | Rendering markdown from plugin or host data | +| `MarkdownEditor` | Editing markdown with the host editor treatment | +| `FileTree` | Showing serializable workspace/wiki/import paths | +| `IssuesList` | Embedding a company-scoped native issue list | +| `AssigneePicker` | Selecting an agent or board user with the same picker as the new issue pane | +| `ProjectPicker` | Selecting a project with the same picker as the new issue pane | +| `ManagedRoutinesList` | Showing plugin-managed routines in settings UI | + +#### Shared Markdown Components + +Plugin UI can render markdown and edit markdown using the same host components +used by Paperclip issue comments and documents: + +```tsx +import { MarkdownBlock, MarkdownEditor } from "@paperclipai/plugin-sdk/ui"; + +export function WikiPageEditor() { + const [body, setBody] = useState("# Wiki page"); + + return ( + <> + + + + ); +} +``` + +`MarkdownBlock` can opt into Obsidian-style wikilinks when a plugin owns the +target URL shape: + +```tsx + +``` + +#### Shared FileTree + +Plugin UI can render the host file tree without importing host internals: + +```tsx +import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui"; + +const nodes: FileTreeNode[] = [ + { name: "AGENTS.md", path: "AGENTS.md", kind: "file", children: [] }, + { + name: "wiki", + path: "wiki", + kind: "dir", + children: [ + { name: "index.md", path: "wiki/index.md", kind: "file", children: [] }, + ], + }, +]; + +export function WikiFiles() { + return ( + console.log("toggle", path)} + onSelectFile={(path) => console.log("select", path)} + /> + ); +} +``` + +#### Shared Assignee and Project Pickers + +Use `AssigneePicker` and `ProjectPicker` when a plugin needs to create, filter, +or configure work against Paperclip entities. Both are controlled components and +load their options from the host for the provided company. + +```tsx +import { AssigneePicker, ProjectPicker } from "@paperclipai/plugin-sdk/ui"; + +export function AssignmentControls({ companyId }: { companyId: string }) { + const [assignee, setAssignee] = useState(""); + const [projectId, setProjectId] = useState(""); + + return ( + <> + { + setAssignee(value); + console.log(selection.assigneeAgentId, selection.assigneeUserId); + }} + /> + + + ); +} +``` ### Slot component props @@ -639,6 +807,7 @@ Each slot type receives a typed props object with `context: PluginHostContext`. |-----------|----------------|------------------| | `page` | `PluginPageProps` | — | | `sidebar` | `PluginSidebarProps` | — | +| `routeSidebar` | `PluginRouteSidebarProps` | — | | `settingsPage` | `PluginSettingsPageProps` | — | | `dashboardWidget` | `PluginWidgetProps` | — | | `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — | @@ -741,14 +910,17 @@ Plugins can add a link under each project in the sidebar via the `projectSidebar Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec): ```tsx -import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui"; +import { + useHostNavigation, + type PluginProjectSidebarItemProps, +} from "@paperclipai/plugin-sdk/ui"; export function FilesLink({ context }: PluginProjectSidebarItemProps) { + const hostNavigation = useHostNavigation(); const projectId = context.entityId; - const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; const projectRef = projectId; // or resolve from host; entityId is project id return ( - + Files ); diff --git a/packages/plugins/sdk/src/bundlers.ts b/packages/plugins/sdk/src/bundlers.ts index b17989e600f..e761489c098 100644 --- a/packages/plugins/sdk/src/bundlers.ts +++ b/packages/plugins/sdk/src/bundlers.ts @@ -89,11 +89,12 @@ export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}) const esbuildManifest: EsbuildLikeOptions = { entryPoints: [manifestEntry], outdir, - bundle: false, + bundle: true, format: "esm", platform: "node", target: "node20", sourcemap, + external: ["@paperclipai/plugin-sdk"], }; const esbuildUi = uiEntry diff --git a/packages/plugins/sdk/src/host-client-factory.ts b/packages/plugins/sdk/src/host-client-factory.ts index 8e1af813d6f..13f0a5925de 100644 --- a/packages/plugins/sdk/src/host-client-factory.ts +++ b/packages/plugins/sdk/src/host-client-factory.ts @@ -90,6 +90,16 @@ export interface HostServices { get(): Promise>; }; + /** Provides trusted company-scoped local folder helpers. */ + localFolders: { + declarations(params: WorkerToHostMethods["localFolders.declarations"][0]): Promise; + configure(params: WorkerToHostMethods["localFolders.configure"][0]): Promise; + status(params: WorkerToHostMethods["localFolders.status"][0]): Promise; + list(params: WorkerToHostMethods["localFolders.list"][0]): Promise; + readText(params: WorkerToHostMethods["localFolders.readText"][0]): Promise; + writeTextAtomic(params: WorkerToHostMethods["localFolders.writeTextAtomic"][0]): Promise; + }; + /** Provides `state.get`, `state.set`, `state.delete`. */ state: { get(params: WorkerToHostMethods["state.get"][0]): Promise; @@ -165,6 +175,18 @@ export interface HostServices { listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise; getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise; getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise; + getManaged(params: WorkerToHostMethods["projects.managed.get"][0]): Promise; + reconcileManaged(params: WorkerToHostMethods["projects.managed.reconcile"][0]): Promise; + resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise; + }; + + /** Provides `routines.managed.*`. */ + routines: { + managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise; + managedReconcile(params: WorkerToHostMethods["routines.managed.reconcile"][0]): Promise; + managedReset(params: WorkerToHostMethods["routines.managed.reset"][0]): Promise; + managedUpdate(params: WorkerToHostMethods["routines.managed.update"][0]): Promise; + managedRun(params: WorkerToHostMethods["routines.managed.run"][0]): Promise; }; /** Provides issue read/write, relation, checkout, wakeup, summary, comment methods. */ @@ -202,6 +224,9 @@ export interface HostServices { pause(params: WorkerToHostMethods["agents.pause"][0]): Promise; resume(params: WorkerToHostMethods["agents.resume"][0]): Promise; invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise; + managedGet(params: WorkerToHostMethods["agents.managed.get"][0]): Promise; + managedReconcile(params: WorkerToHostMethods["agents.managed.reconcile"][0]): Promise; + managedReset(params: WorkerToHostMethods["agents.managed.reset"][0]): Promise; }; /** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */ @@ -281,6 +306,14 @@ const METHOD_CAPABILITY_MAP: Record { + return services.localFolders.declarations(params); + }), + "localFolders.configure": gated("localFolders.configure", async (params) => { + return services.localFolders.configure(params); + }), + "localFolders.status": gated("localFolders.status", async (params) => { + return services.localFolders.status(params); + }), + "localFolders.list": gated("localFolders.list", async (params) => { + return services.localFolders.list(params); + }), + "localFolders.readText": gated("localFolders.readText", async (params) => { + return services.localFolders.readText(params); + }), + "localFolders.writeTextAtomic": gated("localFolders.writeTextAtomic", async (params) => { + return services.localFolders.writeTextAtomic(params); + }), + // State "state.get": gated("state.get", async (params) => { return services.state.get(params); @@ -530,6 +593,32 @@ export function createHostClientHandlers( "projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => { return services.projects.getWorkspaceForIssue(params); }), + "projects.managed.get": gated("projects.managed.get", async (params) => { + return services.projects.getManaged(params); + }), + "projects.managed.reconcile": gated("projects.managed.reconcile", async (params) => { + return services.projects.reconcileManaged(params); + }), + "projects.managed.reset": gated("projects.managed.reset", async (params) => { + return services.projects.resetManaged(params); + }), + + // Routines + "routines.managed.get": gated("routines.managed.get", async (params) => { + return services.routines.managedGet(params); + }), + "routines.managed.reconcile": gated("routines.managed.reconcile", async (params) => { + return services.routines.managedReconcile(params); + }), + "routines.managed.reset": gated("routines.managed.reset", async (params) => { + return services.routines.managedReset(params); + }), + "routines.managed.update": gated("routines.managed.update", async (params) => { + return services.routines.managedUpdate(params); + }), + "routines.managed.run": gated("routines.managed.run", async (params) => { + return services.routines.managedRun(params); + }), // Issues "issues.list": gated("issues.list", async (params) => { @@ -611,6 +700,15 @@ export function createHostClientHandlers( "agents.invoke": gated("agents.invoke", async (params) => { return services.agents.invoke(params); }), + "agents.managed.get": gated("agents.managed.get", async (params) => { + return services.agents.managedGet(params); + }), + "agents.managed.reconcile": gated("agents.managed.reconcile", async (params) => { + return services.agents.managedReconcile(params); + }), + "agents.managed.reset": gated("agents.managed.reset", async (params) => { + return services.agents.managedReset(params); + }), // Agent Sessions "agents.sessions.create": gated("agents.sessions.create", async (params) => { diff --git a/packages/plugins/sdk/src/index.ts b/packages/plugins/sdk/src/index.ts index 92b60bbfb00..3d0bd07e19e 100644 --- a/packages/plugins/sdk/src/index.ts +++ b/packages/plugins/sdk/src/index.ts @@ -180,6 +180,13 @@ export type { export type { PluginContext, PluginConfigClient, + PluginLocalFolderProblem, + PluginLocalFolderStatus, + PluginLocalFolderConfigureInput, + PluginLocalFolderListOptions, + PluginLocalFolderEntry, + PluginLocalFolderListing, + PluginLocalFoldersClient, PluginEventsClient, PluginJobsClient, PluginLaunchersClient, @@ -255,6 +262,14 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginManagedAgentDeclaration, + PluginManagedAgentResolution, + PluginManagedProjectDeclaration, + PluginManagedProjectResolution, + PluginManagedRoutineDeclaration, + PluginManagedRoutineResolution, + PluginManagedResourceKind, + PluginManagedResourceRef, PluginUiSlotDeclaration, PluginUiDeclaration, PluginLauncherActionDeclaration, @@ -264,6 +279,8 @@ export type { PluginDatabaseDeclaration, PluginApiRouteCompanyResolution, PluginApiRouteDeclaration, + PluginLocalFolderDeclaration, + PluginCompanySettings, PluginRecord, PluginDatabaseNamespaceRecord, PluginMigrationRecord, diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 19566570c15..6961c9294bc 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -29,8 +29,14 @@ import type { IssueDocumentSummary, IssueThreadInteraction, CreateIssueThreadInteraction, + PluginManagedAgentResolution, + PluginManagedProjectResolution, + PluginManagedRoutineResolution, + Routine, + RoutineRun, Agent, Goal, + PluginLocalFolderDeclaration, } from "@paperclipai/shared"; export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared"; @@ -46,6 +52,8 @@ import type { PluginWorkspace, ToolRunContext, ToolResult, + PluginLocalFolderListing, + PluginLocalFolderStatus, } from "./types.js"; import type { PluginHealthDiagnostics, @@ -566,6 +574,44 @@ export interface WorkerToHostMethods { // Config "config.get": [params: Record, result: Record]; + // Trusted local folders + "localFolders.declarations": [ + params: Record, + result: PluginLocalFolderDeclaration[], + ]; + "localFolders.configure": [ + params: { + companyId: string; + folderKey: string; + path: string; + access?: "read" | "readWrite"; + requiredDirectories?: string[]; + requiredFiles?: string[]; + }, + result: PluginLocalFolderStatus, + ]; + "localFolders.status": [ + params: { companyId: string; folderKey: string }, + result: PluginLocalFolderStatus, + ]; + "localFolders.list": [ + params: { companyId: string; folderKey: string; relativePath?: string | null; recursive?: boolean; maxEntries?: number }, + result: PluginLocalFolderListing, + ]; + "localFolders.readText": [ + params: { companyId: string; folderKey: string; relativePath: string }, + result: string, + ]; + "localFolders.writeTextAtomic": [ + params: { + companyId: string; + folderKey: string; + relativePath: string; + contents: string; + }, + result: PluginLocalFolderStatus, + ]; + // State "state.get": [ params: { scopeKind: string; scopeId?: string; namespace?: string; stateKey: string }, @@ -724,6 +770,57 @@ export interface WorkerToHostMethods { params: { issueId: string; companyId: string }, result: PluginWorkspace | null, ]; + "projects.managed.get": [ + params: { projectKey: string; companyId: string }, + result: PluginManagedProjectResolution, + ]; + "projects.managed.reconcile": [ + params: { projectKey: string; companyId: string }, + result: PluginManagedProjectResolution, + ]; + "projects.managed.reset": [ + params: { projectKey: string; companyId: string }, + result: PluginManagedProjectResolution, + ]; + "routines.managed.get": [ + params: { routineKey: string; companyId: string }, + result: PluginManagedRoutineResolution, + ]; + "routines.managed.reconcile": [ + params: { + routineKey: string; + companyId: string; + assigneeAgentId?: string | null; + projectId?: string | null; + }, + result: PluginManagedRoutineResolution, + ]; + "routines.managed.reset": [ + params: { + routineKey: string; + companyId: string; + assigneeAgentId?: string | null; + projectId?: string | null; + }, + result: PluginManagedRoutineResolution, + ]; + "routines.managed.update": [ + params: { + routineKey: string; + companyId: string; + status?: string; + }, + result: Routine, + ]; + "routines.managed.run": [ + params: { + routineKey: string; + companyId: string; + assigneeAgentId?: string | null; + projectId?: string | null; + }, + result: RoutineRun, + ]; // Issues "issues.list": [ @@ -732,8 +829,10 @@ export interface WorkerToHostMethods { projectId?: string; assigneeAgentId?: string; originKind?: string; + originKindPrefix?: string; originId?: string; status?: string; + includePluginOperations?: boolean; limit?: number; offset?: number; }, @@ -758,6 +857,7 @@ export interface WorkerToHostMethods { assigneeUserId?: string | null; requestDepth?: number; billingCode?: string | null; + surfaceVisibility?: string | null; originKind?: string | null; originId?: string | null; originRunId?: string | null; @@ -940,6 +1040,18 @@ export interface WorkerToHostMethods { params: { agentId: string; companyId: string; prompt: string; reason?: string }, result: { runId: string }, ]; + "agents.managed.get": [ + params: { agentKey: string; companyId: string }, + result: PluginManagedAgentResolution, + ]; + "agents.managed.reconcile": [ + params: { agentKey: string; companyId: string }, + result: PluginManagedAgentResolution, + ]; + "agents.managed.reset": [ + params: { agentKey: string; companyId: string }, + result: PluginManagedAgentResolution, + ]; // Agent Sessions "agents.sessions.create": [ diff --git a/packages/plugins/sdk/src/testing.ts b/packages/plugins/sdk/src/testing.ts index 16efee05d02..5ed1c1c4fa9 100644 --- a/packages/plugins/sdk/src/testing.ts +++ b/packages/plugins/sdk/src/testing.ts @@ -1,11 +1,16 @@ import { randomUUID } from "node:crypto"; +import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import type { PaperclipPluginManifestV1, PluginCapability, PluginEventType, PluginIssueOriginKind, + PluginManagedAgentResolution, + PluginManagedRoutineResolution, Company, Project, + Routine, + RoutineRun, Issue, IssueComment, IssueThreadInteraction, @@ -419,6 +424,8 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const entityExternalIndex = new Map(); const companies = new Map(); const projects = new Map(); + const routines = new Map(); + const routineRuns = new Map(); const issues = new Map(); const blockedByIssueIds = new Map(); const issueComments = new Map(); @@ -465,6 +472,53 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { } const defaultPluginOriginKind: PluginIssueOriginKind = `plugin:${manifest.id}`; + + function managedAgentDeclaration(agentKey: string) { + const declaration = manifest.agents?.find((agent) => agent.agentKey === agentKey); + if (!declaration) throw new Error(`Managed agent declaration not found: ${agentKey}`); + return declaration; + } + + function isManagedAgent(agent: Agent, agentKey: string) { + const marker = agent.metadata?.paperclipManagedResource; + return Boolean( + marker + && typeof marker === "object" + && !Array.isArray(marker) + && (marker as Record).pluginKey === manifest.id + && (marker as Record).resourceKind === "agent" + && (marker as Record).resourceKey === agentKey, + ); + } + + function managedAgentMetadata(agentKey: string, existing?: Record | null) { + return { + ...(existing ?? {}), + paperclipManagedResource: { + pluginKey: manifest.id, + resourceKind: "agent", + resourceKey: agentKey, + }, + }; + } + + function managedResolution( + agentKey: string, + companyId: string, + agent: Agent | null, + status: PluginManagedAgentResolution["status"], + ): PluginManagedAgentResolution { + return { + pluginKey: manifest.id, + resourceKind: "agent", + resourceKey: agentKey, + companyId, + agentId: agent?.id ?? null, + agent, + status, + approvalId: null, + }; + } function normalizePluginOriginKind(originKind: unknown = defaultPluginOriginKind): PluginIssueOriginKind { if (originKind == null || originKind === "") return defaultPluginOriginKind; if (typeof originKind !== "string") throw new Error("Plugin issue originKind must be a string"); @@ -481,6 +535,81 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { return { ...currentConfig }; }, }, + localFolders: { + declarations() { + return manifest.localFolders ?? []; + }, + async configure(input) { + requireCapability(manifest, capabilitySet, "local.folders"); + return { + folderKey: input.folderKey, + configured: true, + path: input.path, + realPath: input.path, + access: input.access ?? "readWrite", + readable: true, + writable: input.access === "read" ? false : true, + requiredDirectories: input.requiredDirectories ?? [], + requiredFiles: input.requiredFiles ?? [], + missingDirectories: [], + missingFiles: [], + healthy: true, + problems: [], + checkedAt: new Date().toISOString(), + }; + }, + async status(_companyId, folderKey) { + requireCapability(manifest, capabilitySet, "local.folders"); + return { + folderKey, + configured: false, + path: null, + realPath: null, + access: "readWrite", + readable: false, + writable: false, + requiredDirectories: [], + requiredFiles: [], + missingDirectories: [], + missingFiles: [], + healthy: false, + problems: [{ code: "not_configured", message: "No local folder path is configured." }], + checkedAt: new Date().toISOString(), + }; + }, + async list(_companyId, folderKey, options) { + requireCapability(manifest, capabilitySet, "local.folders"); + return { + folderKey, + relativePath: options?.relativePath ?? null, + entries: [], + truncated: false, + }; + }, + async readText() { + requireCapability(manifest, capabilitySet, "local.folders"); + throw new Error("Test harness local folder readText is not implemented"); + }, + async writeTextAtomic(_companyId, folderKey) { + requireCapability(manifest, capabilitySet, "local.folders"); + return { + folderKey, + configured: false, + path: null, + realPath: null, + access: "readWrite", + readable: false, + writable: false, + requiredDirectories: [], + requiredFiles: [], + missingDirectories: [], + missingFiles: [], + healthy: false, + problems: [{ code: "not_configured", message: "No local folder path is configured." }], + checkedAt: new Date().toISOString(), + }; + }, + }, events: { on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise), maybeFn?: (event: PluginEvent) => Promise): () => void { requireCapability(manifest, capabilitySet, "events.subscribe"); @@ -647,6 +776,314 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { const workspaces = projectWorkspaces.get(projectId) ?? []; return workspaces.find((workspace) => workspace.isPrimary) ?? null; }, + managed: { + async get(projectKey, companyId) { + requireCapability(manifest, capabilitySet, "projects.managed"); + const declaration = manifest.projects?.find((project) => project.projectKey === projectKey); + if (!declaration) { + return { + pluginKey: manifest.id, + resourceKind: "project", + resourceKey: projectKey, + companyId, + projectId: null, + project: null, + status: "missing", + }; + } + const externalId = `${manifest.id}:project:${projectKey}`; + const existingEntity = [...entities.values()].find((entity) => + entity.entityType === "managed_resource" + && entity.scopeKind === "company" + && entity.scopeId === companyId + && entity.externalId === externalId + ); + const existingProject = existingEntity ? projects.get(String(existingEntity.data?.projectId ?? "")) : null; + if (existingProject && isInCompany(existingProject, companyId)) { + return { + pluginKey: manifest.id, + resourceKind: "project", + resourceKey: projectKey, + companyId, + projectId: existingProject.id, + project: existingProject, + status: "resolved", + }; + } + const now = new Date(); + const project = { + id: `project-${projects.size + 1}`, + companyId, + urlKey: declaration.projectKey, + goalId: null, + goalIds: [], + goals: [], + name: declaration.displayName, + description: declaration.description ?? null, + status: declaration.status ?? "in_progress", + leadAgentId: null, + targetDate: null, + color: declaration.color ?? null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: `/tmp/${declaration.projectKey}`, + effectiveLocalFolder: `/tmp/${declaration.projectKey}`, + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + managedByPlugin: { + id: `managed-${projects.size + 1}`, + pluginId: manifest.id, + pluginKey: manifest.id, + pluginDisplayName: manifest.displayName, + resourceKind: "project", + resourceKey: projectKey, + defaultsJson: { displayName: declaration.displayName, settings: declaration.settings ?? {} }, + createdAt: now, + updatedAt: now, + }, + archivedAt: null, + createdAt: now, + updatedAt: now, + } as Project; + projects.set(project.id, project); + const externalKey = `managed_resource|company|${companyId}|${externalId}`; + const nowIso = now.toISOString(); + const record: PluginEntityRecord = { + id: randomUUID(), + entityType: "managed_resource", + scopeKind: "company", + scopeId: companyId, + externalId, + title: declaration.displayName, + status: null, + data: { resourceKind: "project", resourceKey: projectKey, projectId: project.id }, + createdAt: nowIso, + updatedAt: nowIso, + }; + entities.set(record.id, record); + entityExternalIndex.set(externalKey, record.id); + return { + pluginKey: manifest.id, + resourceKind: "project", + resourceKey: projectKey, + companyId, + projectId: project.id, + project, + status: "created", + }; + }, + async reconcile(projectKey, companyId) { + return this.get(projectKey, companyId); + }, + async reset(projectKey, companyId) { + const resolved = await this.get(projectKey, companyId); + return { ...resolved, status: resolved.project ? "reset" : resolved.status }; + }, + }, + }, + routines: { + managed: { + async get(routineKey, companyId) { + requireCapability(manifest, capabilitySet, "routines.managed"); + const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey); + if (!declaration) { + return { + pluginKey: manifest.id, + resourceKind: "routine", + resourceKey: routineKey, + companyId, + routineId: null, + routine: null, + status: "missing", + missingRefs: [], + } satisfies PluginManagedRoutineResolution; + } + const externalId = `${manifest.id}:routine:${routineKey}`; + const existingEntity = [...entities.values()].find((entity) => + entity.entityType === "managed_resource" + && entity.scopeKind === "company" + && entity.scopeId === companyId + && entity.externalId === externalId + ); + const existingRoutine = existingEntity ? routines.get(String(existingEntity.data?.routineId ?? "")) : null; + if (existingRoutine && isInCompany(existingRoutine, companyId)) { + return { + pluginKey: manifest.id, + resourceKind: "routine", + resourceKey: routineKey, + companyId, + routineId: existingRoutine.id, + routine: existingRoutine, + status: "resolved", + missingRefs: [], + } satisfies PluginManagedRoutineResolution; + } + return { + pluginKey: manifest.id, + resourceKind: "routine", + resourceKey: routineKey, + companyId, + routineId: null, + routine: null, + status: "missing", + missingRefs: [], + } satisfies PluginManagedRoutineResolution; + }, + async reconcile(routineKey, companyId, overrides) { + const existing = await this.get(routineKey, companyId); + if (existing.routine) return existing; + const declaration = manifest.routines?.find((routine) => routine.routineKey === routineKey); + if (!declaration) return existing; + const now = new Date(); + const agentRef = declaration.assigneeRef; + const projectRef = declaration.projectRef; + const assigneeAgentId = overrides?.assigneeAgentId + ?? (agentRef?.resourceKind === "agent" + ? [...agents.values()].find((agent) => isInCompany(agent, companyId) && isManagedAgent(agent, agentRef.resourceKey))?.id + : null) + ?? null; + const projectId = overrides?.projectId + ?? (projectRef?.resourceKind === "project" + ? [...projects.values()].find((project) => ( + isInCompany(project, companyId) + && project.managedByPlugin?.pluginKey === manifest.id + && project.managedByPlugin?.resourceKey === projectRef.resourceKey + ))?.id + : null) + ?? null; + const missingRefs: NonNullable = []; + if (agentRef && !assigneeAgentId) missingRefs.push({ ...agentRef, pluginKey: manifest.id }); + if (projectRef && !projectId) missingRefs.push({ ...projectRef, pluginKey: manifest.id }); + if (missingRefs.length > 0) { + return { + pluginKey: manifest.id, + resourceKind: "routine", + resourceKey: routineKey, + companyId, + routineId: null, + routine: null, + status: "missing_refs", + missingRefs, + } satisfies PluginManagedRoutineResolution; + } + const routine = { + id: `routine-${routines.size + 1}`, + companyId, + projectId, + goalId: declaration.goalId ?? null, + parentIssueId: null, + title: declaration.title, + description: declaration.description ?? null, + assigneeAgentId, + priority: declaration.priority ?? "medium", + status: declaration.status ?? (assigneeAgentId ? "active" : "paused"), + concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", + catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + variables: declaration.variables ?? [], + createdByAgentId: null, + createdByUserId: null, + updatedByAgentId: null, + updatedByUserId: null, + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: now, + updatedAt: now, + managedByPlugin: { + id: `managed-routine-${routines.size + 1}`, + pluginId: manifest.id, + pluginKey: manifest.id, + pluginDisplayName: manifest.displayName, + resourceKind: "routine", + resourceKey: routineKey, + defaultsJson: { title: declaration.title, issueTemplate: declaration.issueTemplate ?? null }, + createdAt: now, + updatedAt: now, + }, + } as Routine; + routines.set(routine.id, routine); + const nowIso = now.toISOString(); + const record: PluginEntityRecord = { + id: randomUUID(), + entityType: "managed_resource", + scopeKind: "company", + scopeId: companyId, + externalId: `${manifest.id}:routine:${routineKey}`, + title: declaration.title, + status: null, + data: { resourceKind: "routine", resourceKey: routineKey, routineId: routine.id }, + createdAt: nowIso, + updatedAt: nowIso, + }; + entities.set(record.id, record); + return { + pluginKey: manifest.id, + resourceKind: "routine", + resourceKey: routineKey, + companyId, + routineId: routine.id, + routine, + status: "created", + missingRefs: [], + } satisfies PluginManagedRoutineResolution; + }, + async reset(routineKey, companyId, overrides) { + const resolved = await this.reconcile(routineKey, companyId, overrides); + return { ...resolved, status: resolved.routine ? "reset" : resolved.status } satisfies PluginManagedRoutineResolution; + }, + async update(routineKey, companyId, patch) { + const resolved = await this.get(routineKey, companyId); + if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`); + const next = { + ...resolved.routine, + ...(patch.status !== undefined ? { status: patch.status } : {}), + updatedAt: new Date(), + }; + routines.set(next.id, next); + return next; + }, + async run(routineKey, companyId) { + const resolved = await this.get(routineKey, companyId); + if (!resolved.routine) throw new Error(`Managed routine not found: ${routineKey}`); + const now = new Date(); + const run = { + id: `routine-run-${routineRuns.size + 1}`, + companyId, + routineId: resolved.routine.id, + triggerId: null, + source: "manual", + status: "queued", + triggeredAt: now, + idempotencyKey: null, + triggerPayload: null, + dispatchFingerprint: null, + linkedIssueId: null, + coalescedIntoRunId: null, + failureReason: null, + completedAt: null, + createdAt: now, + updatedAt: now, + } satisfies RoutineRun; + routineRuns.set(run.id, run); + routines.set(resolved.routine.id, { + ...resolved.routine, + lastTriggeredAt: now, + lastEnqueuedAt: now, + updatedAt: now, + }); + return run; + }, + }, }, companies: { async list(input) { @@ -673,6 +1110,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { if (input.originKind.startsWith("plugin:")) normalizePluginOriginKind(input.originKind); out = out.filter((issue) => issue.originKind === input.originKind); } + if (input?.originKindPrefix) { + const prefix = input.originKindPrefix; + out = out.filter((issue) => + typeof issue.originKind === "string" && issue.originKind.startsWith(prefix), + ); + } if (input?.originId) out = out.filter((issue) => issue.originId === input.originId); if (input?.status) out = out.filter((issue) => issue.status === input.status); if (input?.offset) out = out.slice(input.offset); @@ -687,6 +1130,11 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { async create(input) { requireCapability(manifest, capabilitySet, "issues.create"); const now = new Date(); + const originKind = normalizePluginOriginKind( + input.surfaceVisibility === "plugin_operation" && !input.originKind + ? pluginOperationIssueOriginKind(manifest.id) + : input.originKind, + ); const record: Issue = { id: randomUUID(), companyId: input.companyId, @@ -708,7 +1156,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { createdByUserId: null, issueNumber: null, identifier: null, - originKind: normalizePluginOriginKind(input.originKind), + originKind, originId: input.originId ?? null, originRunId: input.originRunId ?? null, requestDepth: input.requestDepth ?? 0, @@ -1064,6 +1512,115 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness { } return { runId: randomUUID() }; }, + managed: { + async get(agentKey, companyId) { + requireCapability(manifest, capabilitySet, "agents.managed"); + const cid = requireCompanyId(companyId); + managedAgentDeclaration(agentKey); + const agent = [...agents.values()].find((candidate) => + candidate.companyId === cid && + candidate.status !== "terminated" && + isManagedAgent(candidate, agentKey), + ) ?? null; + return managedResolution(agentKey, cid, agent, agent ? "resolved" : "missing"); + }, + async reconcile(agentKey, companyId) { + requireCapability(manifest, capabilitySet, "agents.managed"); + const cid = requireCompanyId(companyId); + const declaration = managedAgentDeclaration(agentKey); + const existingAgent = [...agents.values()].find((candidate) => + candidate.companyId === cid && + candidate.status !== "terminated" && + isManagedAgent(candidate, agentKey), + ) ?? null; + const existing = managedResolution(agentKey, cid, existingAgent, existingAgent ? "resolved" : "missing"); + if (existing.agent) return existing; + const now = new Date(); + const created: Agent = { + id: randomUUID(), + companyId: cid, + name: declaration.displayName, + urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""), + role: (declaration.role ?? "general") as Agent["role"], + title: declaration.title ?? null, + icon: declaration.icon ?? null, + status: declaration.status ?? "idle", + reportsTo: null, + capabilities: declaration.capabilities ?? null, + adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"], + adapterConfig: declaration.adapterConfig ?? {}, + runtimeConfig: declaration.runtimeConfig ?? {}, + budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) }, + lastHeartbeatAt: null, + metadata: managedAgentMetadata(agentKey), + createdAt: now, + updatedAt: now, + }; + agents.set(created.id, created); + return managedResolution(agentKey, cid, created, "created"); + }, + async reset(agentKey, companyId) { + requireCapability(manifest, capabilitySet, "agents.managed"); + const cid = requireCompanyId(companyId); + const declaration = managedAgentDeclaration(agentKey); + let agent = [...agents.values()].find((candidate) => + candidate.companyId === cid && + candidate.status !== "terminated" && + isManagedAgent(candidate, agentKey), + ) ?? null; + if (!agent) { + const now = new Date(); + agent = { + id: randomUUID(), + companyId: cid, + name: declaration.displayName, + urlKey: declaration.displayName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""), + role: (declaration.role ?? "general") as Agent["role"], + title: declaration.title ?? null, + icon: declaration.icon ?? null, + status: declaration.status ?? "idle", + reportsTo: null, + capabilities: declaration.capabilities ?? null, + adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"], + adapterConfig: declaration.adapterConfig ?? {}, + runtimeConfig: declaration.runtimeConfig ?? {}, + budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, + spentMonthlyCents: 0, + pauseReason: null, + pausedAt: null, + permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) }, + lastHeartbeatAt: null, + metadata: managedAgentMetadata(agentKey), + createdAt: now, + updatedAt: now, + }; + agents.set(agent.id, agent); + } + const resolved = managedResolution(agentKey, cid, agent, "resolved"); + if (!resolved.agent) return resolved; + const updated: Agent = { + ...resolved.agent, + name: declaration.displayName, + role: (declaration.role ?? "general") as Agent["role"], + title: declaration.title ?? null, + icon: declaration.icon ?? null, + capabilities: declaration.capabilities ?? null, + adapterType: (declaration.adapterType ?? "process") as Agent["adapterType"], + adapterConfig: declaration.adapterConfig ?? {}, + runtimeConfig: declaration.runtimeConfig ?? {}, + budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, + permissions: { canCreateAgents: Boolean(declaration.permissions?.canCreateAgents) }, + metadata: managedAgentMetadata(agentKey, resolved.agent.metadata), + updatedAt: new Date(), + }; + agents.set(updated.id, updated); + return managedResolution(agentKey, cid, updated, "reset"); + }, + }, sessions: { async create(agentId, companyId, opts) { requireCapability(manifest, capabilitySet, "agent.sessions.create"); diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 599a06ecac9..f4a946f9ecc 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -28,6 +28,12 @@ import type { RequestConfirmationInteraction, CreateIssueThreadInteraction, PluginIssueOriginKind, + IssueSurfaceVisibility, + PluginManagedAgentResolution, + PluginManagedProjectResolution, + PluginManagedRoutineResolution, + Routine, + RoutineRun, Agent, Goal, } from "@paperclipai/shared"; @@ -42,6 +48,18 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginManagedAgentDeclaration, + PluginManagedAgentResolution, + PluginManagedProjectDeclaration, + PluginManagedProjectResolution, + PluginManagedRoutineDeclaration, + PluginManagedRoutineResolution, + Routine, + RoutineRun, + PluginLocalFolderDeclaration, + PluginCompanySettings, + PluginManagedResourceKind, + PluginManagedResourceRef, PluginUiSlotDeclaration, PluginUiDeclaration, PluginLauncherActionDeclaration, @@ -92,6 +110,7 @@ export type { RequestConfirmationInteraction, CreateIssueThreadInteraction, PluginIssueOriginKind, + IssueSurfaceVisibility, Agent, Goal, } from "@paperclipai/shared"; @@ -349,6 +368,90 @@ export interface PluginConfigClient { get(): Promise>; } +export interface PluginLocalFolderProblem { + code: + | "not_configured" + | "not_absolute" + | "missing" + | "not_directory" + | "not_readable" + | "not_writable" + | "missing_directory" + | "missing_file" + | "path_traversal" + | "symlink_escape" + | "atomic_write_failed"; + message: string; + path?: string; +} + +export interface PluginLocalFolderStatus { + folderKey: string; + configured: boolean; + path: string | null; + realPath: string | null; + access: "read" | "readWrite"; + readable: boolean; + writable: boolean; + requiredDirectories: string[]; + requiredFiles: string[]; + missingDirectories: string[]; + missingFiles: string[]; + healthy: boolean; + problems: PluginLocalFolderProblem[]; + checkedAt: string; +} + +export interface PluginLocalFolderConfigureInput { + companyId: string; + folderKey: string; + path: string; + access?: "read" | "readWrite"; + requiredDirectories?: string[]; + requiredFiles?: string[]; +} + +export interface PluginLocalFolderListOptions { + relativePath?: string | null; + recursive?: boolean; + maxEntries?: number; +} + +export interface PluginLocalFolderEntry { + path: string; + name: string; + kind: "file" | "directory"; + size: number | null; + modifiedAt: string | null; +} + +export interface PluginLocalFolderListing { + folderKey: string; + relativePath: string | null; + entries: PluginLocalFolderEntry[]; + truncated: boolean; +} + +export interface PluginLocalFoldersClient { + /** Manifest-declared local folders for this plugin. */ + declarations(): import("@paperclipai/shared").PluginLocalFolderDeclaration[]; + /** Persist a company-scoped local folder path after validating it. */ + configure(input: PluginLocalFolderConfigureInput): Promise; + /** Check the stored folder readiness for a company and folder key. */ + status(companyId: string, folderKey: string): Promise; + /** List entries below a configured folder after containment checks. */ + list(companyId: string, folderKey: string, options?: PluginLocalFolderListOptions): Promise; + /** Read a UTF-8 text file below a configured folder after containment checks. */ + readText(companyId: string, folderKey: string, relativePath: string): Promise; + /** Write a UTF-8 text file below a configured folder using atomic rename. */ + writeTextAtomic( + companyId: string, + folderKey: string, + relativePath: string, + contents: string, + ): Promise; +} + /** * `ctx.events` — subscribe to and emit Paperclip domain events. * @@ -697,6 +800,44 @@ export interface PluginProjectsClient { * @see PLUGIN_SPEC.md §20 — Local Tooling */ getWorkspaceForIssue(issueId: string, companyId: string): Promise; + + /** Resolve and reconcile manifest-declared plugin-managed projects by stable key. Requires `projects.managed`. */ + managed: { + get(projectKey: string, companyId: string): Promise; + reconcile(projectKey: string, companyId: string): Promise; + reset(projectKey: string, companyId: string): Promise; + }; +} + +/** + * `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines. + * + * Requires `routines.managed` capability. + */ +export interface PluginRoutinesClient { + managed: { + get(routineKey: string, companyId: string): Promise; + reconcile( + routineKey: string, + companyId: string, + overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, + ): Promise; + reset( + routineKey: string, + companyId: string, + overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, + ): Promise; + update( + routineKey: string, + companyId: string, + patch: { status?: string }, + ): Promise; + run( + routineKey: string, + companyId: string, + overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, + ): Promise; + }; } /** @@ -1099,8 +1240,10 @@ export interface PluginIssuesClient { projectId?: string; assigneeAgentId?: string; originKind?: PluginIssueOriginKind; + originKindPrefix?: string; originId?: string; status?: Issue["status"]; + includePluginOperations?: boolean; limit?: number; offset?: number; }): Promise; @@ -1119,6 +1262,7 @@ export interface PluginIssuesClient { assigneeUserId?: string | null; requestDepth?: number; billingCode?: string | null; + surfaceVisibility?: IssueSurfaceVisibility; originKind?: PluginIssueOriginKind; originId?: string | null; originRunId?: string | null; @@ -1241,6 +1385,12 @@ export interface PluginAgentsClient { resume(agentId: string, companyId: string): Promise; /** Invoke (wake up) an agent with a prompt payload. Throws if paused, terminated, pending_approval, or not found. Requires `agents.invoke`. */ invoke(agentId: string, companyId: string, opts: { prompt: string; reason?: string }): Promise<{ runId: string }>; + /** Resolve and reconcile manifest-declared plugin-managed agents by stable key. Requires `agents.managed`. */ + managed: { + get(agentKey: string, companyId: string): Promise; + reconcile(agentKey: string, companyId: string): Promise; + reset(agentKey: string, companyId: string): Promise; + }; /** Create, message, and close agent chat sessions. Requires `agent.sessions.*` capabilities. */ sessions: PluginAgentSessionsClient; } @@ -1436,6 +1586,9 @@ export interface PluginContext { /** Read resolved operator configuration. */ config: PluginConfigClient; + /** Configure and safely access trusted company-scoped local folders. */ + localFolders: PluginLocalFoldersClient; + /** Subscribe to and emit domain events. Requires `events.subscribe` / `events.emit`. */ events: PluginEventsClient; @@ -1466,6 +1619,9 @@ export interface PluginContext { /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */ projects: PluginProjectsClient; + /** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */ + routines: PluginRoutinesClient; + /** Read company metadata. Requires `companies.read`. */ companies: PluginCompaniesClient; diff --git a/packages/plugins/sdk/src/ui/components.ts b/packages/plugins/sdk/src/ui/components.ts index b93c1db44e7..c148fc8014e 100644 --- a/packages/plugins/sdk/src/ui/components.ts +++ b/packages/plugins/sdk/src/ui/components.ts @@ -125,6 +125,36 @@ export interface TimeseriesChartProps { export interface MarkdownBlockProps { /** Markdown content to render. */ content: string; + /** Optional CSS class name forwarded to the host renderer. */ + className?: string; + /** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */ + enableWikiLinks?: boolean; + /** Base href used for wikilinks when no resolver is supplied. */ + wikiLinkRoot?: string; + /** Optional href resolver for wikilinks. Return null to leave a token as plain text. */ + resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined; +} + +/** Props for `MarkdownEditor`. */ +export interface MarkdownEditorProps { + /** Markdown source controlled by the plugin. */ + value: string; + /** Called whenever the markdown source changes. */ + onChange: (value: string) => void; + /** Placeholder text shown when the document is empty. */ + placeholder?: string; + /** Optional wrapper CSS class name. */ + className?: string; + /** Optional editable content CSS class name. */ + contentClassName?: string; + /** Called when the editor loses focus. */ + onBlur?: () => void; + /** Render the editor with a host border treatment. */ + bordered?: boolean; + /** Render the rich editor without allowing edits. */ + readOnly?: boolean; + /** Called on Cmd/Ctrl+Enter. */ + onSubmit?: () => void; } /** A single key-value pair for `KeyValueList`. */ @@ -217,6 +247,211 @@ export interface ErrorBoundaryProps { onError?: (error: Error, info: React.ErrorInfo) => void; } +/** File or directory node rendered by `FileTree`. */ +export interface FileTreeNode { + /** Display name for this path segment. */ + name: string; + /** Slash-separated path relative to the tree root. */ + path: string; + /** Whether this node is a directory or file. */ + kind: "dir" | "file"; + /** Child nodes. Files should use an empty array. */ + children: FileTreeNode[]; + /** Optional stable action metadata for host/plugin workflows. */ + action?: string | null; +} + +/** Badge status variants supported by `FileTree`. */ +export type FileTreeBadgeVariant = "ok" | "warning" | "error" | "info" | "pending"; + +/** Serializable badge metadata keyed by file path. */ +export interface FileTreeBadge { + label: string; + status: FileTreeBadgeVariant; + tooltip?: string; +} + +/** Row tone variants supported by `FileTree`. */ +export type FileTreeTone = "default" | "warning" | "error" | "muted"; + +/** Empty-state content shown when a tree has no nodes. */ +export interface FileTreeEmptyState { + title?: string; + description?: string; +} + +/** Error-state content shown when a tree cannot be loaded. */ +export interface FileTreeErrorState { + message: string; + retry?: () => void; +} + +/** Accepted path collection shape for expanded and checked file tree state. */ +export type FileTreePathCollection = ReadonlySet | readonly string[]; + +/** Props for `FileTree`. */ +export interface FileTreeProps { + /** Tree nodes to render. */ + nodes: FileTreeNode[]; + /** Currently selected file path. */ + selectedFile?: string | null; + /** Expanded directory paths. */ + expandedPaths?: FileTreePathCollection; + /** Checked file paths. */ + checkedPaths?: FileTreePathCollection; + /** Called when a directory row is toggled. */ + onToggleDir?: (path: string) => void; + /** Called when a file row is selected. */ + onSelectFile?: (path: string) => void; + /** Called when a checkbox is toggled. */ + onToggleCheck?: (path: string, kind: "file" | "dir") => void; + /** Badge metadata keyed by path. */ + fileBadges?: Record; + /** Row tone metadata keyed by path. */ + fileTones?: Record; + /** Whether to render checkboxes. Defaults to false for plugin UIs. */ + showCheckboxes?: boolean; + /** Allow long file and directory names to wrap. */ + wrapLabels?: boolean; + /** Render a loading skeleton instead of nodes. */ + loading?: boolean; + /** Render a structured error state instead of nodes. */ + error?: FileTreeErrorState | null; + /** Empty state content. */ + empty?: FileTreeEmptyState; + /** Accessible label for the tree. */ + ariaLabel?: string; +} + +export interface IssuesListFilters { + status?: string; + projectId?: string; + parentId?: string; + assigneeAgentId?: string; + participantAgentId?: string; + assigneeUserId?: string; + labelId?: string; + workspaceId?: string; + executionWorkspaceId?: string; + originKind?: string; + originKindPrefix?: string; + originId?: string; + descendantOf?: string; + includeRoutineExecutions?: boolean; +} + +export interface IssuesListProps { + companyId: string | null; + projectId?: string | null; + filters?: IssuesListFilters; + viewStateKey?: string; + initialSearch?: string; + createIssueLabel?: string; + searchWithinLoadedIssues?: boolean; +} + +export interface AssigneePickerSelection { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface AssigneePickerProps { + /** Company whose agents and users should be listed. Defaults to host context. */ + companyId?: string | null; + /** Controlled value. Use `agent:`, `user:`, or an empty string. */ + value: string; + /** Called with the encoded value plus parsed assignee IDs. */ + onChange: (value: string, selection: AssigneePickerSelection) => void; + /** Button placeholder when no assignee is selected. */ + placeholder?: string; + /** Label for the empty option. */ + noneLabel?: string; + /** Search input placeholder. */ + searchPlaceholder?: string; + /** Empty search result message. */ + emptyMessage?: string; + /** Include active board users alongside agents. Defaults to true. */ + includeUsers?: boolean; + /** Include terminated agents. Defaults to false. */ + includeTerminatedAgents?: boolean; + /** CSS class forwarded to the trigger button. */ + className?: string; + /** Called after the user confirms a selection with Enter, Tab, or click. */ + onConfirm?: () => void; +} + +export interface ProjectPickerProps { + /** Company whose projects should be listed. Defaults to host context. */ + companyId?: string | null; + /** Controlled project id, or an empty string for no project. */ + value: string; + /** Called with the selected project id. Empty string means no project. */ + onChange: (projectId: string) => void; + /** Button placeholder when no project is selected. */ + placeholder?: string; + /** Label for the empty option. */ + noneLabel?: string; + /** Search input placeholder. */ + searchPlaceholder?: string; + /** Empty search result message. */ + emptyMessage?: string; + /** Include archived projects. Defaults to false. */ + includeArchived?: boolean; + /** CSS class forwarded to the trigger button. */ + className?: string; + /** Called after the user confirms a selection with Enter, Tab, or click. */ + onConfirm?: () => void; +} + +export interface ManagedRoutinesListAgent { + id: string; + name: string; + icon?: string | null; +} + +export interface ManagedRoutinesListProject { + id: string; + name: string; + color?: string | null; +} + +export interface ManagedRoutineMissingRef { + resourceKind: string; + resourceKey: string; +} + +export interface ManagedRoutinesListItem { + key: string; + title: string; + status: string; + routineId?: string | null; + href?: string | null; + resourceKey?: string | null; + projectId?: string | null; + assigneeAgentId?: string | null; + cronExpression?: string | null; + lastRunAt?: Date | string | null; + lastRunStatus?: string | null; + managedByPluginDisplayName?: string | null; + missingRefs?: ManagedRoutineMissingRef[]; +} + +export interface ManagedRoutinesListProps { + routines: ManagedRoutinesListItem[]; + agents?: ManagedRoutinesListAgent[]; + projects?: ManagedRoutinesListProject[]; + pluginDisplayName?: string | null; + emptyMessage?: string; + runningRoutineKey?: string | null; + statusMutationRoutineKey?: string | null; + reconcilingRoutineKey?: string | null; + resettingRoutineKey?: string | null; + onRunNow?: (routine: ManagedRoutinesListItem) => void; + onToggleEnabled?: (routine: ManagedRoutinesListItem, enabled: boolean) => void; + onReconcile?: (routine: ManagedRoutinesListItem) => void; + onReset?: (routine: ManagedRoutinesListItem) => void; +} + // --------------------------------------------------------------------------- // Component declarations (provided by host at runtime) // --------------------------------------------------------------------------- @@ -266,6 +501,13 @@ export const TimeseriesChart = createSdkUiComponent("Times */ export const MarkdownBlock = createSdkUiComponent("MarkdownBlock"); +/** + * Renders Paperclip's shared Markdown editor. + * + * @see PLUGIN_SPEC.md §19.6 — Shared Components + */ +export const MarkdownEditor = createSdkUiComponent("MarkdownEditor"); + /** * Renders a definition-list of label/value pairs. * @@ -308,3 +550,40 @@ export const Spinner = createSdkUiComponent("Spinner"); * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ export const ErrorBoundary = createSdkUiComponent("ErrorBoundary"); + +/** + * Renders the host file tree component with a stable plugin-safe prop surface. + * + * @example + * ```tsx + * import { FileTree, type FileTreeNode } from "@paperclipai/plugin-sdk/ui"; + * + * const nodes: FileTreeNode[] = [ + * { name: "README.md", path: "README.md", kind: "file", children: [] }, + * ]; + * + * console.log(path)} />; + * ``` + */ +export const FileTree = createSdkUiComponent("FileTree"); + +/** + * Renders Paperclip's native issue list component for company-scoped plugin + * pages that need a standard board issue view. + */ +export const IssuesList = createSdkUiComponent("IssuesList"); + +/** + * Renders the same host assignee picker used by the new issue pane. + */ +export const AssigneePicker = createSdkUiComponent("AssigneePicker"); + +/** + * Renders the same host project picker used by the new issue pane. + */ +export const ProjectPicker = createSdkUiComponent("ProjectPicker"); + +/** + * Renders Paperclip's native managed routines list for plugin settings pages. + */ +export const ManagedRoutinesList = createSdkUiComponent("ManagedRoutinesList"); diff --git a/packages/plugins/sdk/src/ui/hooks.ts b/packages/plugins/sdk/src/ui/hooks.ts index 56a0938b802..686d02cf4b3 100644 --- a/packages/plugins/sdk/src/ui/hooks.ts +++ b/packages/plugins/sdk/src/ui/hooks.ts @@ -1,6 +1,8 @@ import type { PluginDataResult, PluginActionFn, + HostLocation, + HostNavigation, PluginHostContext, PluginStreamResult, PluginToastFn, @@ -115,6 +117,57 @@ export function useHostContext(): PluginHostContext { return impl(); } +// --------------------------------------------------------------------------- +// useHostNavigation +// --------------------------------------------------------------------------- + +/** + * Navigate within the Paperclip host without forcing a full document reload. + * + * Use `linkProps()` for links so browser-native behavior still works: + * modifier-click, middle-click, copy-link, and open-in-new-tab all use the + * returned real `href`. + * + * @example + * ```tsx + * function WikiSidebarLink() { + * const hostNavigation = useHostNavigation(); + * return Wiki; + * } + * ``` + */ +export function useHostNavigation(): HostNavigation { + const impl = getSdkUiRuntimeValue<() => HostNavigation>("useHostNavigation"); + return impl(); +} + +// --------------------------------------------------------------------------- +// useHostLocation +// --------------------------------------------------------------------------- + +/** + * Observe the current host router location. + * + * Returns a snapshot of the active `pathname`, `search`, and `hash`. The + * component re-renders when any of these change (e.g. after the host router + * pushes a new entry, or after the browser back/forward gestures). Use this + * for URL-driven plugin UI such as a takeover sidebar with section-aware + * active state. + * + * @example + * ```tsx + * function WikiSection() { + * const { pathname } = useHostLocation(); + * const section = pathname.split("/").filter(Boolean).at(-1) ?? "wiki"; + * return
    Active section: {section}
    ; + * } + * ``` + */ +export function useHostLocation(): HostLocation { + const impl = getSdkUiRuntimeValue<() => HostLocation>("useHostLocation"); + return impl(); +} + // --------------------------------------------------------------------------- // usePluginStream // --------------------------------------------------------------------------- diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index 68b8a43ba42..8f90af70a3a 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -43,20 +43,89 @@ * - `usePluginData(key, params)` — fetch data from the worker's `getData` handler * - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler * - `useHostContext()` — read the current active company, project, entity, and user IDs + * - `useHostNavigation()` — navigate Paperclip-internal links through the host router + * - `useHostLocation()` — observe the current host pathname/search/hash for URL-driven UI * - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker */ export { usePluginData, usePluginAction, useHostContext, + useHostNavigation, + useHostLocation, usePluginStream, usePluginToast, } from "./hooks.js"; +export { + MetricCard, + StatusBadge, + DataTable, + TimeseriesChart, + MarkdownBlock, + MarkdownEditor, + KeyValueList, + ActionBar, + LogView, + JsonTree, + Spinner, + ErrorBoundary, + FileTree, + IssuesList, + AssigneePicker, + ProjectPicker, + ManagedRoutinesList, +} from "./components.js"; + +export type { + MetricTrend, + MetricCardProps, + StatusBadgeVariant, + StatusBadgeProps, + DataTableColumn, + DataTableProps, + TimeseriesDataPoint, + TimeseriesChartProps, + MarkdownBlockProps, + MarkdownEditorProps, + KeyValuePair, + KeyValueListProps, + ActionBarItem, + ActionBarProps, + LogViewEntry, + LogViewProps, + JsonTreeProps, + SpinnerProps, + ErrorBoundaryProps, + FileTreeNode, + FileTreeBadgeVariant, + FileTreeBadge, + FileTreeTone, + FileTreeEmptyState, + FileTreeErrorState, + FileTreePathCollection, + FileTreeProps, + IssuesListFilters, + IssuesListProps, + AssigneePickerSelection, + AssigneePickerProps, + ProjectPickerProps, + ManagedRoutineMissingRef, + ManagedRoutinesListAgent, + ManagedRoutinesListItem, + ManagedRoutinesListProject, + ManagedRoutinesListProps, +} from "./components.js"; + // Bridge error and host context types export type { PluginBridgeError, PluginBridgeErrorCode, + HostNavigation, + HostNavigationOptions, + HostNavigationLinkOptions, + HostNavigationLinkProps, + HostLocation, PluginHostContext, PluginModalBoundsRequest, PluginRenderCloseEvent, @@ -80,6 +149,7 @@ export type { PluginWidgetProps, PluginDetailTabProps, PluginSidebarProps, + PluginRouteSidebarProps, PluginProjectSidebarItemProps, PluginCommentAnnotationProps, PluginCommentContextMenuItemProps, diff --git a/packages/plugins/sdk/src/ui/types.ts b/packages/plugins/sdk/src/ui/types.ts index b1eddea5a22..b8216836157 100644 --- a/packages/plugins/sdk/src/ui/types.ts +++ b/packages/plugins/sdk/src/ui/types.ts @@ -14,6 +14,10 @@ * @see PLUGIN_SPEC.md §29.2 — SDK Versioning */ +import type { + AnchorHTMLAttributes, + MouseEvent as ReactMouseEvent, +} from "react"; import type { PluginBridgeErrorCode, PluginLauncherBounds, @@ -131,6 +135,83 @@ export interface PluginRenderEnvironmentContext closeLifecycle?: PluginRenderCloseLifecycle | null; } +// --------------------------------------------------------------------------- +// Host navigation +// --------------------------------------------------------------------------- + +/** + * Options for host-managed Paperclip navigation from plugin UI. + */ +export interface HostNavigationOptions { + /** Replace the current history entry instead of pushing a new one. */ + replace?: boolean; + /** Optional state forwarded to the host router. */ + state?: unknown; +} + +/** + * Options for `useHostNavigation().linkProps()`. + */ +export interface HostNavigationLinkOptions extends HostNavigationOptions { + /** Standard anchor target. Non-`_self` targets are not intercepted. */ + target?: AnchorHTMLAttributes["target"]; + /** Standard anchor rel attribute. */ + rel?: AnchorHTMLAttributes["rel"]; +} + +/** + * Anchor props returned by `useHostNavigation().linkProps()`. + * + * The `href` is always real so browser affordances such as copy-link, + * modifier-click, middle-click, and open-in-new-tab continue to work. + */ +export interface HostNavigationLinkProps + extends Pick, "href" | "target" | "rel"> { + onClick: (event: ReactMouseEvent) => void; +} + +/** + * Snapshot of the host router location, exposed to plugin UI through + * `useHostLocation()`. Mirrors the relevant subset of `Location` from + * `react-router-dom` so plugins can react to URL changes without importing + * router internals. + * + * @see PLUGIN_SPEC.md §19 — UI Extension Model + */ +export interface HostLocation { + /** Current pathname, e.g. `/PAP/wiki`. */ + pathname: string; + /** Current search string, e.g. `?tab=config` (includes the leading `?`). */ + search: string; + /** Current hash, e.g. `#document-plan` (includes the leading `#`). */ + hash: string; + /** Optional state forwarded by the host router for same-tab SPA navigation. */ + state?: unknown; +} + +/** + * Host-managed navigation helpers for plugin UI. + */ +export interface HostNavigation { + /** + * Resolve a Paperclip-internal path using the active company prefix. + * + * For example, in company `PAP`, `resolveHref("/wiki")` returns + * `"/PAP/wiki"`, while `resolveHref("/PAP/wiki")` stays unchanged. + */ + resolveHref(to: string): string; + /** Navigate through the host router without reloading the document. */ + navigate(to: string, options?: HostNavigationOptions): void; + /** + * Build anchor props for host-managed links. + * + * Plain left-clicks are routed through the host SPA router. Browser-native + * link gestures are left alone because the returned props include a real + * `href`. + */ + linkProps(to: string, options?: HostNavigationLinkOptions): HostNavigationLinkProps; +} + // --------------------------------------------------------------------------- // Slot component prop interfaces // --------------------------------------------------------------------------- @@ -188,6 +269,19 @@ export interface PluginSidebarProps { context: PluginHostContext; } +/** + * Props passed to a plugin route sidebar component. + * + * A route sidebar replaces the normal company sidebar while the user is on a + * matching plugin page route declared with the same `routePath`. + * + * @see PLUGIN_SPEC.md §19.5 — Sidebar Entries + */ +export interface PluginRouteSidebarProps { + /** The current host context. */ + context: PluginHostContext; +} + /** * Props passed to a plugin project sidebar item component. * diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index 80e548b5f66..7683605d3c4 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -387,6 +387,51 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost }, }, + localFolders: { + declarations() { + if (!manifest) throw new Error("Plugin context accessed before initialization"); + return manifest.localFolders ?? []; + }, + + async configure(input) { + return callHost("localFolders.configure", { + companyId: input.companyId, + folderKey: input.folderKey, + path: input.path, + access: input.access, + requiredDirectories: input.requiredDirectories, + requiredFiles: input.requiredFiles, + }); + }, + + async status(companyId: string, folderKey: string) { + return callHost("localFolders.status", { companyId, folderKey }); + }, + + async list(companyId: string, folderKey: string, options = {}) { + return callHost("localFolders.list", { + companyId, + folderKey, + relativePath: options.relativePath, + recursive: options.recursive, + maxEntries: options.maxEntries, + }); + }, + + async readText(companyId: string, folderKey: string, relativePath: string) { + return callHost("localFolders.readText", { companyId, folderKey, relativePath }); + }, + + async writeTextAtomic(companyId: string, folderKey: string, relativePath: string, contents: string) { + return callHost("localFolders.writeTextAtomic", { + companyId, + folderKey, + relativePath, + contents, + }); + }, + }, + events: { on( name: string, @@ -580,6 +625,50 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost async getWorkspaceForIssue(issueId: string, companyId: string) { return callHost("projects.getWorkspaceForIssue", { issueId, companyId }); }, + + managed: { + async get(projectKey: string, companyId: string) { + return callHost("projects.managed.get", { projectKey, companyId }); + }, + async reconcile(projectKey: string, companyId: string) { + return callHost("projects.managed.reconcile", { projectKey, companyId }); + }, + async reset(projectKey: string, companyId: string) { + return callHost("projects.managed.reset", { projectKey, companyId }); + }, + }, + }, + + routines: { + managed: { + async get(routineKey: string, companyId: string) { + return callHost("routines.managed.get", { routineKey, companyId }); + }, + async reconcile( + routineKey: string, + companyId: string, + overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, + ) { + return callHost("routines.managed.reconcile", { routineKey, companyId, ...overrides }); + }, + async reset( + routineKey: string, + companyId: string, + overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, + ) { + return callHost("routines.managed.reset", { routineKey, companyId, ...overrides }); + }, + async update(routineKey: string, companyId: string, patch: { status?: string }) { + return callHost("routines.managed.update", { routineKey, companyId, ...patch }); + }, + async run( + routineKey: string, + companyId: string, + overrides?: { assigneeAgentId?: string | null; projectId?: string | null }, + ) { + return callHost("routines.managed.run", { routineKey, companyId, ...overrides }); + }, + }, }, companies: { @@ -602,8 +691,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost projectId: input.projectId, assigneeAgentId: input.assigneeAgentId, originKind: input.originKind, + originKindPrefix: input.originKindPrefix, originId: input.originId, status: input.status, + includePluginOperations: input.includePluginOperations, limit: input.limit, offset: input.offset, }); @@ -628,6 +719,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost assigneeUserId: input.assigneeUserId, requestDepth: input.requestDepth, billingCode: input.billingCode, + surfaceVisibility: input.surfaceVisibility, originKind: input.originKind, originId: input.originId, originRunId: input.originRunId, @@ -863,6 +955,20 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost return callHost("agents.invoke", { agentId, companyId, prompt: opts.prompt, reason: opts.reason }); }, + managed: { + async get(agentKey: string, companyId: string) { + return callHost("agents.managed.get", { agentKey, companyId }); + }, + + async reconcile(agentKey: string, companyId: string) { + return callHost("agents.managed.reconcile", { agentKey, companyId }); + }, + + async reset(agentKey: string, companyId: string) { + return callHost("agents.managed.reset", { agentKey, companyId }); + }, + }, + sessions: { async create(agentId: string, companyId: string, opts?: { taskKey?: string; reason?: string }) { return callHost("agents.sessions.create", { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 3724d42d8ab..001ccee26f2 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -190,6 +190,16 @@ export const ISSUE_ORIGIN_KINDS = [ export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number]; export type PluginIssueOriginKind = `plugin:${string}`; export type IssueOriginKind = BuiltInIssueOriginKind | PluginIssueOriginKind; +export const ISSUE_SURFACE_VISIBILITIES = ["default", "plugin_operation"] as const; +export type IssueSurfaceVisibility = (typeof ISSUE_SURFACE_VISIBILITIES)[number]; + +export function pluginOperationIssueOriginKind(pluginKey: string): PluginIssueOriginKind { + return `plugin:${pluginKey}:operation`; +} + +export function isPluginOperationIssueOriginKind(originKind: string | null | undefined): boolean { + return typeof originKind === "string" && /^plugin:[^:]+:operation(?::|$)/.test(originKind); +} export const ISSUE_RELATION_TYPES = ["blocks"] as const; export type IssueRelationType = (typeof ISSUE_RELATION_TYPES)[number]; @@ -634,9 +644,12 @@ export const PLUGIN_CAPABILITIES = [ "issue.comments.create", "issue.interactions.create", "issue.documents.write", + "projects.managed", + "routines.managed", "agents.pause", "agents.resume", "agents.invoke", + "agents.managed", "agent.sessions.create", "agent.sessions.list", "agent.sessions.send", @@ -658,6 +671,7 @@ export const PLUGIN_CAPABILITIES = [ "http.outbound", "secrets.read-ref", "environment.drivers.register", + "local.folders", // Agent Tools "agent.tools.register", // UI @@ -728,6 +742,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "taskDetailView", "dashboardWidget", "sidebar", + "routeSidebar", "sidebarPanel", "projectSidebarItem", "globalToolbarButton", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 87a298c77aa..810ac1e6bd3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -25,6 +25,9 @@ export { ISSUE_THREAD_INTERACTION_STATUSES, ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES, ISSUE_ORIGIN_KINDS, + ISSUE_SURFACE_VISIBILITIES, + pluginOperationIssueOriginKind, + isPluginOperationIssueOriginKind, ISSUE_RELATION_TYPES, ISSUE_TREE_CONTROL_MODES, ISSUE_TREE_HOLD_RELEASE_POLICY_STRATEGIES, @@ -133,6 +136,7 @@ export { type BuiltInIssueOriginKind, type PluginIssueOriginKind, type IssueOriginKind, + type IssueSurfaceVisibility, type IssueRelationType, type IssueTreeControlMode, type IssueTreeHoldReleasePolicyStrategy, @@ -303,6 +307,7 @@ export type { ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, + ProjectManagedByPlugin, ProjectWorkspace, ExecutionWorkspace, ExecutionWorkspaceSummary, @@ -493,6 +498,7 @@ export type { CompanySecret, SecretProviderDescriptor, Routine, + RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, RoutineTrigger, @@ -507,6 +513,15 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginManagedAgentDeclaration, + PluginManagedProjectDeclaration, + PluginManagedRoutineDeclaration, + PluginLocalFolderDeclaration, + PluginManagedAgentResolution, + PluginManagedProjectResolution, + PluginManagedRoutineResolution, + PluginManagedResourceKind, + PluginManagedResourceRef, PluginUiSlotDeclaration, PluginLauncherActionDeclaration, PluginLauncherRenderDeclaration, @@ -523,6 +538,7 @@ export type { PluginMigrationRecord, PluginStateRecord, PluginConfig, + PluginCompanySettings, PluginEntityRecord, PluginEntityQuery, PluginJobRecord, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 35eb94c6378..1b24f771bf5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -89,7 +89,7 @@ export type { AdapterEnvironmentTestResult, } from "./agent.js"; export type { AssetImage } from "./asset.js"; -export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; +export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js"; export type { ExecutionWorkspace, ExecutionWorkspaceSummary, @@ -221,6 +221,7 @@ export type { } from "./secrets.js"; export type { Routine, + RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, RoutineTrigger, @@ -315,6 +316,15 @@ export type { PluginWebhookDeclaration, PluginToolDeclaration, PluginEnvironmentDriverDeclaration, + PluginManagedAgentDeclaration, + PluginManagedProjectDeclaration, + PluginManagedRoutineDeclaration, + PluginLocalFolderDeclaration, + PluginManagedAgentResolution, + PluginManagedProjectResolution, + PluginManagedRoutineResolution, + PluginManagedResourceKind, + PluginManagedResourceRef, PluginUiSlotDeclaration, PluginLauncherActionDeclaration, PluginLauncherRenderDeclaration, @@ -331,6 +341,7 @@ export type { PluginMigrationRecord, PluginStateRecord, PluginConfig, + PluginCompanySettings, PluginEntityRecord, PluginEntityQuery, PluginJobRecord, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 955b719fb4b..d5231aadff5 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -16,7 +16,19 @@ import type { PluginDatabaseMigrationStatus, PluginDatabaseNamespaceMode, PluginDatabaseNamespaceStatus, + AgentAdapterType, + AgentRole, + AgentStatus, + IssuePriority, + ProjectStatus, + RoutineCatchUpPolicy, + RoutineConcurrencyPolicy, + RoutineStatus, + IssueSurfaceVisibility, } from "../constants.js"; +import type { Agent } from "./agent.js"; +import type { Project } from "./project.js"; +import type { Routine, RoutineTrigger, RoutineVariable } from "./routine.js"; // --------------------------------------------------------------------------- // JSON Schema placeholder – plugins declare config schemas as JSON Schema @@ -113,6 +125,162 @@ export interface PluginEnvironmentDriverDeclaration { configSchema: JsonSchema; } +/** + * Declares a normal Paperclip agent that a plugin can provision and later + * resolve by stable key within each company. + */ +export interface PluginManagedAgentDeclaration { + /** Stable identifier for this managed agent, unique within the plugin. */ + agentKey: string; + /** Suggested visible agent name. */ + displayName: string; + /** Optional suggested role. Defaults to `general`. */ + role?: AgentRole | string; + /** Optional suggested title shown in agent surfaces. */ + title?: string | null; + /** Optional icon for agent list/detail surfaces. */ + icon?: string | null; + /** Suggested capability summary for the agent. */ + capabilities?: string | null; + /** Suggested adapter type. Defaults to `process`. */ + adapterType?: AgentAdapterType | string; + /** + * Optional ordered list of compatible adapter types. When present, the host + * prefers the most-used compatible adapter already configured in the company, + * falling back to `adapterType`. + */ + adapterPreference?: Array; + /** Suggested adapter configuration. */ + adapterConfig?: Record; + /** Suggested Paperclip runtime configuration. */ + runtimeConfig?: Record; + /** Suggested permissions object. Normalized by the host on create/reset. */ + permissions?: Record; + /** Suggested starting status when no board approval is required. */ + status?: Extract; + /** Suggested monthly budget in cents. */ + budgetMonthlyCents?: number; + /** Optional managed instructions content or pointer metadata for plugin UI. */ + instructions?: { + entryFile?: string; + content?: string; + assetPath?: string; + }; +} + +/** + * Declares a company-scoped local folder a trusted plugin wants the operator + * to configure. The host treats this as a generic filesystem root: plugin + * code may request required relative folders/files, then use SDK helpers for + * path-safe reads and atomic writes under that root. + */ +export interface PluginLocalFolderDeclaration { + /** Stable identifier for this folder, unique within the plugin. */ + folderKey: string; + /** Human-readable name shown in plugin settings. */ + displayName: string; + /** Optional operator-facing description. */ + description?: string; + /** Access level requested by the plugin. Defaults to `readWrite`. */ + access?: "read" | "readWrite"; + /** Relative directories expected to exist under the configured root. */ + requiredDirectories?: string[]; + /** Relative files expected to exist under the configured root. */ + requiredFiles?: string[]; +} + +/** + * Declares a normal Paperclip project that a plugin can provision and later + * resolve by stable key within each company. + */ +export interface PluginManagedProjectDeclaration { + /** Stable identifier for this managed project, unique within the plugin. */ + projectKey: string; + /** Suggested visible project name. */ + displayName: string; + /** Suggested project description. */ + description?: string | null; + /** Suggested starting status. Defaults to `in_progress`. */ + status?: ProjectStatus; + /** Suggested project color. Defaults to the normal project palette. */ + color?: string | null; + /** Optional plugin-specific defaults retained for reset/reconcile UI. */ + settings?: Record; +} + +export type PluginManagedResourceKind = "agent" | "project" | "routine"; + +export interface PluginManagedResourceRef { + pluginKey?: string; + resourceKind: PluginManagedResourceKind; + resourceKey: string; +} + +export interface PluginManagedRoutineDeclaration { + /** Stable identifier for this managed routine, unique within the plugin. */ + routineKey: string; + /** Suggested routine title template. */ + title: string; + /** Suggested routine description template. */ + description?: string | null; + /** Stable managed agent reference for the default assignee. */ + assigneeRef?: PluginManagedResourceRef | null; + /** Stable managed project reference for routine-created issues. */ + projectRef?: PluginManagedResourceRef | null; + /** Optional goal id to set on the routine in this company. */ + goalId?: string | null; + /** Suggested starting status. Defaults to `paused` when no assignee is resolved, otherwise `active`. */ + status?: RoutineStatus; + /** Suggested issue priority. Defaults to `medium`. */ + priority?: IssuePriority; + /** Suggested concurrency behavior. Defaults to core routine default. */ + concurrencyPolicy?: RoutineConcurrencyPolicy; + /** Suggested missed-trigger behavior. Defaults to core routine default. */ + catchUpPolicy?: RoutineCatchUpPolicy; + /** Suggested routine variables. */ + variables?: RoutineVariable[]; + /** Suggested triggers created when the routine is first reconciled. */ + triggers?: Array>; + /** Defaults for issues created by this routine. */ + issueTemplate?: { + surfaceVisibility?: IssueSurfaceVisibility; + originId?: string | null; + billingCode?: string | null; + }; +} + +export interface PluginManagedAgentResolution { + pluginKey: string; + resourceKind: "agent"; + resourceKey: string; + companyId: string; + agentId: string | null; + agent: Agent | null; + status: "missing" | "resolved" | "created" | "relinked" | "reset"; + approvalId?: string | null; +} + +export interface PluginManagedProjectResolution { + pluginKey: string; + resourceKind: "project"; + resourceKey: string; + companyId: string; + projectId: string | null; + project: Project | null; + status: "missing" | "resolved" | "created" | "relinked" | "reset"; +} + +export interface PluginManagedRoutineResolution { + pluginKey: string; + resourceKind: "routine"; + resourceKey: string; + companyId: string; + routineId: string | null; + routine: Routine | null; + status: "missing" | "missing_refs" | "resolved" | "created" | "relinked" | "reset"; + missingRefs?: PluginManagedResourceRef[]; +} + /** * Declares a UI extension slot the plugin fills with a React component. * @@ -133,7 +301,7 @@ export interface PluginUiSlotDeclaration { */ entityTypes?: PluginUiSlotEntityType[]; /** - * Optional company-scoped route segment for page slots. + * Optional company-scoped route segment for page and routeSidebar slots. * Example: `kitchensink` becomes `/:companyPrefix/kitchensink`. */ routePath?: string; @@ -322,6 +490,14 @@ export interface PaperclipPluginManifestV1 { apiRoutes?: PluginApiRouteDeclaration[]; /** Environment drivers this plugin contributes. Requires `environment.drivers.register` capability. */ environmentDrivers?: PluginEnvironmentDriverDeclaration[]; + /** Suggested company-scoped agents this plugin can provision and resolve by stable key. */ + agents?: PluginManagedAgentDeclaration[]; + /** Suggested company-scoped projects this plugin can provision and resolve by stable key. */ + projects?: PluginManagedProjectDeclaration[]; + /** Suggested company-scoped routines this plugin can provision and resolve by stable key. */ + routines?: PluginManagedRoutineDeclaration[]; + /** Trusted local folders this plugin can configure and access by stable key. */ + localFolders?: PluginLocalFolderDeclaration[]; /** * Legacy top-level launcher declarations. * Prefer `ui.launchers` for new manifests. @@ -455,6 +631,22 @@ export interface PluginConfig { updatedAt: Date; } +/** + * Company-scoped plugin settings row. This is intentionally generic; plugin + * features such as local folders live inside `settingsJson` under namespaced + * keys instead of requiring feature-specific database columns. + */ +export interface PluginCompanySettings { + id: string; + companyId: string; + pluginId: string; + enabled: boolean; + settingsJson: Record; + lastError: string | null; + createdAt: Date; + updatedAt: Date; +} + /** * Query filter for `ctx.entities.list`. */ diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index 2aa50361557..302d504879b 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -52,6 +52,18 @@ export interface ProjectCodebase { origin: ProjectCodebaseOrigin; } +export interface ProjectManagedByPlugin { + id: string; + pluginId: string; + pluginKey: string; + pluginDisplayName: string; + resourceKind: "project"; + resourceKey: string; + defaultsJson: Record; + createdAt: Date; + updatedAt: Date; +} + export interface Project { id: string; companyId: string; @@ -73,6 +85,7 @@ export interface Project { codebase: ProjectCodebase; workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; + managedByPlugin?: ProjectManagedByPlugin | null; archivedAt: Date | null; createdAt: Date; updatedAt: Date; diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index aea256bea46..565c94f009e 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -58,6 +58,19 @@ export interface Routine { lastEnqueuedAt: Date | null; createdAt: Date; updatedAt: Date; + managedByPlugin?: RoutineManagedByPlugin | null; +} + +export interface RoutineManagedByPlugin { + id: string; + pluginId: string; + pluginKey: string; + pluginDisplayName: string; + resourceKind: "routine"; + resourceKey: string; + defaultsJson: Record; + createdAt: Date; + updatedAt: Date; } export interface RoutineTrigger { diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts new file mode 100644 index 00000000000..7de0c31687c --- /dev/null +++ b/packages/shared/src/validators/plugin.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { PLUGIN_CAPABILITIES } from "../constants.js"; +import { pluginManagedRoutineDeclarationSchema, pluginUiSlotDeclarationSchema } from "./plugin.js"; + +describe("plugin capability constants", () => { + it("exposes each capability once", () => { + expect(new Set(PLUGIN_CAPABILITIES).size).toBe(PLUGIN_CAPABILITIES.length); + }); +}); + +describe("plugin managed routine validators", () => { + it("accepts core issue surface visibility values in routine templates", () => { + const parsed = pluginManagedRoutineDeclarationSchema.parse({ + routineKey: "wiki.refresh", + title: "Refresh Wiki", + issueTemplate: { surfaceVisibility: "default" }, + }); + + expect(parsed.issueTemplate?.surfaceVisibility).toBe("default"); + }); + + it("rejects non-core issue surface visibility values in routine templates", () => { + const parsed = pluginManagedRoutineDeclarationSchema.safeParse({ + routineKey: "wiki.refresh", + title: "Refresh Wiki", + issueTemplate: { surfaceVisibility: "normal" }, + }); + + expect(parsed.success).toBe(false); + }); +}); + +describe("plugin UI slot validators", () => { + it("accepts route-scoped sidebar slots with a routePath", () => { + const parsed = pluginUiSlotDeclarationSchema.parse({ + type: "routeSidebar", + id: "wiki-route-sidebar", + displayName: "Wiki Sidebar", + exportName: "WikiSidebar", + routePath: "wiki", + }); + + expect(parsed.routePath).toBe("wiki"); + }); + + it("requires route-scoped sidebar slots to declare a routePath", () => { + const parsed = pluginUiSlotDeclarationSchema.safeParse({ + type: "routeSidebar", + id: "wiki-route-sidebar", + displayName: "Wiki Sidebar", + exportName: "WikiSidebar", + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues[0]?.message).toBe("routeSidebar slots require routePath"); + }); + + it("keeps reserved company route protection for route-scoped sidebars", () => { + const parsed = pluginUiSlotDeclarationSchema.safeParse({ + type: "routeSidebar", + id: "settings-route-sidebar", + displayName: "Settings Sidebar", + exportName: "SettingsSidebar", + routePath: "settings", + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); + }); +}); diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 3ade091245f..336620a0acb 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -15,7 +15,15 @@ import { PLUGIN_API_ROUTE_AUTH_MODES, PLUGIN_API_ROUTE_CHECKOUT_POLICIES, PLUGIN_API_ROUTE_METHODS, + ISSUE_PRIORITIES, + ROUTINE_CATCH_UP_POLICIES, + ROUTINE_CONCURRENCY_POLICIES, + ROUTINE_STATUSES, + ROUTINE_TRIGGER_KINDS, + ROUTINE_TRIGGER_SIGNING_MODES, + ISSUE_SURFACE_VISIBILITIES, } from "../constants.js"; +import { routineVariableSchema } from "./routine.js"; // --------------------------------------------------------------------------- // JSON Schema placeholder – a permissive validator for JSON Schema objects @@ -124,6 +132,106 @@ export type PluginEnvironmentDriverDeclarationInput = z.infer< export type PluginToolDeclarationInput = z.infer; +export const pluginManagedAgentDeclarationSchema = z.object({ + agentKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "agentKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }), + displayName: z.string().min(1).max(100), + role: z.string().min(1).max(100).optional(), + title: z.string().max(200).nullable().optional(), + icon: z.string().max(100).nullable().optional(), + capabilities: z.string().max(2000).nullable().optional(), + adapterType: z.string().min(1).max(100).optional(), + adapterPreference: z.array(z.string().min(1).max(100)).max(10).optional(), + adapterConfig: z.record(z.unknown()).optional(), + runtimeConfig: z.record(z.unknown()).optional(), + permissions: z.record(z.unknown()).optional(), + status: z.enum(["idle", "paused"]).optional(), + budgetMonthlyCents: z.number().int().min(0).optional(), + instructions: z.object({ + entryFile: z.string().min(1).max(200).optional(), + content: z.string().max(200_000).optional(), + assetPath: z.string().min(1).max(500).optional(), + }).optional(), +}); + +export type PluginManagedAgentDeclarationInput = z.infer; + +export const pluginManagedProjectDeclarationSchema = z.object({ + projectKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "projectKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }), + displayName: z.string().min(1).max(120), + description: z.string().max(2000).nullable().optional(), + status: z.enum(["backlog", "planned", "in_progress", "completed", "cancelled"]).optional(), + color: z.string().max(32).nullable().optional(), + settings: z.record(z.unknown()).optional(), +}); + +export type PluginManagedProjectDeclarationInput = z.infer; + +const pluginManagedResourceRefSchema = z.object({ + pluginKey: z.string().min(1).max(100).optional(), + resourceKind: z.enum(["agent", "project", "routine"]), + resourceKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "resourceKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }), +}); + +export const pluginManagedRoutineDeclarationSchema = z.object({ + routineKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "routineKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }), + title: z.string().trim().min(1).max(200), + description: z.string().max(10_000).nullable().optional(), + assigneeRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("agent") }).nullable().optional(), + projectRef: pluginManagedResourceRefSchema.extend({ resourceKind: z.literal("project") }).nullable().optional(), + goalId: z.string().uuid().nullable().optional(), + status: z.enum(ROUTINE_STATUSES).optional(), + priority: z.enum(ISSUE_PRIORITIES).optional(), + concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional(), + catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional(), + variables: z.array(routineVariableSchema).optional(), + triggers: z.array(z.object({ + kind: z.enum(ROUTINE_TRIGGER_KINDS), + label: z.string().trim().max(120).nullable().optional(), + enabled: z.boolean().optional(), + cronExpression: z.string().trim().min(1).optional().nullable(), + timezone: z.string().trim().min(1).optional().nullable(), + signingMode: z.enum(ROUTINE_TRIGGER_SIGNING_MODES).optional().nullable(), + replayWindowSec: z.number().int().min(30).max(86_400).optional().nullable(), + })).max(20).optional(), + issueTemplate: z.object({ + surfaceVisibility: z.enum(ISSUE_SURFACE_VISIBILITIES).optional(), + originId: z.string().trim().max(255).nullable().optional(), + billingCode: z.string().trim().max(200).nullable().optional(), + }).optional(), +}); + +export type PluginManagedRoutineDeclarationInput = z.infer; + +const pluginLocalFolderRelativePathSchema = z.string().min(1).max(500).refine( + (value) => + !value.startsWith("/") && + !value.includes("..") && + !value.includes("\\") && + !value.split("/").some((segment) => segment === "" || segment === "."), + { message: "local folder paths must be relative paths without traversal, empty segments, or backslashes" }, +); + +export const pluginLocalFolderDeclarationSchema = z.object({ + folderKey: z.string().min(1).max(100).regex(/^[a-z0-9][a-z0-9._:-]*$/, { + message: "folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens", + }), + displayName: z.string().min(1).max(100), + description: z.string().max(500).optional(), + access: z.enum(["read", "readWrite"]).optional(), + requiredDirectories: z.array(pluginLocalFolderRelativePathSchema).optional(), + requiredFiles: z.array(pluginLocalFolderRelativePathSchema).optional(), +}); + +export type PluginLocalFolderDeclarationInput = z.infer; + /** * Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin * fills with a React component. Includes `superRefine` checks for slot-specific @@ -178,10 +286,17 @@ export const pluginUiSlotDeclarationSchema = z.object({ path: ["entityTypes"], }); } - if (value.routePath && value.type !== "page") { + if (value.routePath && value.type !== "page" && value.type !== "routeSidebar") { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "routePath is only supported for page slots", + message: "routePath is only supported for page and routeSidebar slots", + path: ["routePath"], + }); + } + if (value.type === "routeSidebar" && !value.routePath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "routeSidebar slots require routePath", path: ["routePath"], }); } @@ -471,6 +586,10 @@ export const pluginManifestV1Schema = z.object({ database: pluginDatabaseDeclarationSchema.optional(), apiRoutes: z.array(pluginApiRouteDeclarationSchema).optional(), environmentDrivers: z.array(pluginEnvironmentDriverDeclarationSchema).optional(), + agents: z.array(pluginManagedAgentDeclarationSchema).optional(), + projects: z.array(pluginManagedProjectDeclarationSchema).optional(), + routines: z.array(pluginManagedRoutineDeclarationSchema).optional(), + localFolders: z.array(pluginLocalFolderDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(), @@ -529,6 +648,46 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.agents && manifest.agents.length > 0) { + if (!manifest.capabilities.includes("agents.managed")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'agents.managed' is required when managed agents are declared", + path: ["capabilities"], + }); + } + } + + if (manifest.projects && manifest.projects.length > 0) { + if (!manifest.capabilities.includes("projects.managed")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'projects.managed' is required when managed projects are declared", + path: ["capabilities"], + }); + } + } + + if (manifest.routines && manifest.routines.length > 0) { + if (!manifest.capabilities.includes("routines.managed")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'routines.managed' is required when managed routines are declared", + path: ["capabilities"], + }); + } + } + + if (manifest.localFolders && manifest.localFolders.length > 0) { + if (!manifest.capabilities.includes("local.folders")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Capability 'local.folders' is required when local folders are declared", + path: ["capabilities"], + }); + } + } + // jobs require jobs.schedule (PLUGIN_SPEC.md §17) if (manifest.jobs && manifest.jobs.length > 0) { if (!manifest.capabilities.includes("jobs.schedule")) { @@ -664,6 +823,54 @@ export const pluginManifestV1Schema = z.object({ } } + if (manifest.localFolders) { + const folderKeys = manifest.localFolders.map((folder) => folder.folderKey); + const duplicates = folderKeys.filter((key, i) => folderKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate local folder keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["localFolders"], + }); + } + } + + if (manifest.agents) { + const agentKeys = manifest.agents.map((agent) => agent.agentKey); + const duplicates = agentKeys.filter((key, i) => agentKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate managed agent keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["agents"], + }); + } + } + + if (manifest.projects) { + const projectKeys = manifest.projects.map((project) => project.projectKey); + const duplicates = projectKeys.filter((key, i) => projectKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate managed project keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["projects"], + }); + } + } + + if (manifest.routines) { + const routineKeys = manifest.routines.map((routine) => routine.routineKey); + const duplicates = routineKeys.filter((key, i) => routineKeys.indexOf(key) !== i); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate managed routine keys: ${[...new Set(duplicates)].join(", ")}`, + path: ["routines"], + }); + } + } + // UI slot ids must be unique within the plugin (namespaced at runtime) if (manifest.ui) { if (manifest.ui.slots) { diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index eeb82316cc2..2c7054951d5 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -657,6 +657,143 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(projectResult.map((issue) => issue.id).sort()).toEqual([executionLinkedIssueId, projectLinkedIssueId].sort()); }); + it("hides plugin operation issues from default lists and inbox-style filters while preserving explicit retrieval", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + const normalIssueId = randomUUID(); + const pluginVisibleIssueId = randomUUID(); + const operationIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Plugin Runner", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Plugin operations", + status: "in_progress", + }); + await db.insert(issues).values([ + { + id: normalIssueId, + companyId, + title: "Normal issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + }, + { + id: pluginVisibleIssueId, + companyId, + title: "Plugin-visible issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclip.missions:feature", + }, + { + id: operationIssueId, + companyId, + projectId, + title: "Plugin operation issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + originKind: "plugin:paperclip.missions:operation", + originId: "mission-alpha:operation-1", + }, + ]); + + const defaultIssueIds = (await svc.list(companyId)).map((issue) => issue.id); + expect(defaultIssueIds).toContain(normalIssueId); + expect(defaultIssueIds).toContain(pluginVisibleIssueId); + expect(defaultIssueIds).not.toContain(operationIssueId); + + const inboxIssueIds = (await svc.list(companyId, { + assigneeAgentId: agentId, + status: "todo,in_progress,blocked", + includeRoutineExecutions: true, + })).map((issue) => issue.id); + expect(inboxIssueIds).toContain(normalIssueId); + expect(inboxIssueIds).not.toContain(operationIssueId); + + await expect(svc.list(companyId, { originKind: "plugin:paperclip.missions:operation" })) + .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); + await expect(svc.list(companyId, { originId: "mission-alpha:operation-1" })) + .resolves.toEqual([expect.objectContaining({ id: operationIssueId })]); + + const projectIssueIds = (await svc.list(companyId, { projectId })).map((issue) => issue.id); + expect(projectIssueIds).toContain(operationIssueId); + + const advancedIssueIds = (await svc.list(companyId, { includePluginOperations: true })).map((issue) => issue.id); + expect(advancedIssueIds).toContain(operationIssueId); + }); + + it("excludes plugin operation issues from unread inbox counts", async () => { + const companyId = randomUUID(); + const userId = "board-user"; + const otherUserId = "other-user"; + const normalIssueId = randomUUID(); + const operationIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values([ + { + id: normalIssueId, + companyId, + title: "Normal touched issue", + status: "todo", + priority: "medium", + createdByUserId: userId, + }, + { + id: operationIssueId, + companyId, + title: "Plugin operation touched issue", + status: "todo", + priority: "medium", + createdByUserId: userId, + originKind: "plugin:paperclip.missions:operation", + }, + ]); + await db.insert(issueComments).values([ + { + companyId, + issueId: normalIssueId, + authorUserId: otherUserId, + body: "Unread normal update.", + }, + { + companyId, + issueId: operationIssueId, + authorUserId: otherUserId, + body: "Unread operation update.", + }, + ]); + + await expect(svc.countUnreadTouchedByUser(companyId, userId, "todo")).resolves.toBe(1); + }); + it("hides archived inbox issues until new external activity arrives", async () => { const companyId = randomUUID(); const userId = "user-1"; diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index 9aaca2f15e1..6392b78e791 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { companies, createDb, @@ -25,9 +25,11 @@ import { validatePluginRuntimeExecute, validatePluginRuntimeQuery, } from "../services/plugin-database.js"; +import { pluginLoader } from "../services/plugin-loader.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const multiMigrationPluginKey = "paperclip.dbfixture"; if (!embeddedPostgresSupport.supported) { console.warn( @@ -93,7 +95,7 @@ describeEmbeddedPostgres("plugin database namespaces", () => { }, 20_000); afterEach(async () => { - for (const pluginKey of ["paperclip.dbtest", "paperclip.escape"]) { + for (const pluginKey of ["paperclip.dbtest", "paperclip.escape", "paperclip.refresh", multiMigrationPluginKey]) { const namespace = derivePluginDatabaseNamespace(pluginKey); await db.execute(sql.raw(`DROP SCHEMA IF EXISTS "${namespace}" CASCADE`)); } @@ -120,6 +122,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => { return packageRoot; } + async function createInstallablePluginPackage( + pluginManifest: PaperclipPluginManifestV1, + migrationSql: string, + ) { + const packageRoot = await createPluginPackage(pluginManifest, migrationSql); + await writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ + name: pluginManifest.id, + version: pluginManifest.version, + type: "module", + paperclipPlugin: { manifest: "./manifest.js" }, + }), + "utf8", + ); + await writeFile( + path.join(packageRoot, "manifest.js"), + `export default ${JSON.stringify(pluginManifest, null, 2)};\n`, + "utf8", + ); + await mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await writeFile(path.join(packageRoot, "dist", "worker.js"), "export {};\n", "utf8"); + return packageRoot; + } + async function installPluginRecord(manifest: PaperclipPluginManifestV1) { const pluginId = randomUUID(); await db.insert(plugins).values({ @@ -158,6 +185,31 @@ describeEmbeddedPostgres("plugin database namespaces", () => { }; } + it("applies multi-file plugin migrations through the production validator", async () => { + const pluginManifest = manifest(multiMigrationPluginKey); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id); + const packageRoot = await createPluginPackage( + pluginManifest, + `CREATE TABLE ${namespace}.source_rows (id uuid PRIMARY KEY, label text NOT NULL);`, + ); + await writeFile( + path.join(packageRoot, pluginManifest.database!.migrationsDir, "002_derived.sql"), + `CREATE TABLE ${namespace}.derived_rows ( + id uuid PRIMARY KEY, + source_id uuid NOT NULL REFERENCES ${namespace}.source_rows(id) + );`, + "utf8", + ); + const pluginId = await installPluginRecord(pluginManifest); + await pluginDatabaseService(db).applyMigrations(pluginId, pluginManifest, packageRoot); + + const migrations = await db + .select() + .from(pluginMigrations) + .where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.status, "applied"))); + expect(migrations).toHaveLength(2); + }); + it("applies migrations once and allows whitelisted core joins at runtime", async () => { const pluginManifest = manifest(); const namespace = derivePluginDatabaseNamespace(pluginManifest.id); @@ -246,6 +298,131 @@ describeEmbeddedPostgres("plugin database namespaces", () => { expect(migration?.status).toBe("failed"); }); + it("rolls back plugin install when migration validation fails", async () => { + const pluginManifest = manifest("paperclip.escape"); + const namespace = derivePluginDatabaseNamespace(pluginManifest.id); + const packageRoot = await createInstallablePluginPackage( + pluginManifest, + "CREATE TABLE public.plugin_escape (id uuid PRIMARY KEY);", + ); + const loader = pluginLoader(db, { + enableLocalFilesystem: false, + enableNpmDiscovery: false, + }); + + await expect(loader.installPlugin({ localPath: packageRoot })) + .rejects.toThrow(/public\.plugin_escape|public/i); + + const installedPlugins = await db + .select() + .from(plugins) + .where(eq(plugins.pluginKey, pluginManifest.id)); + const namespaces = await db + .select() + .from(pluginDatabaseNamespaces) + .where(eq(pluginDatabaseNamespaces.pluginKey, pluginManifest.id)); + const migrations = await db + .select() + .from(pluginMigrations) + .where(eq(pluginMigrations.pluginKey, pluginManifest.id)); + const schemaRows = Array.from( + await db.execute( + sql<{ schema_name: string }>`SELECT schema_name FROM information_schema.schemata WHERE schema_name = ${namespace}`, + ) as Iterable<{ schema_name: string }>, + ); + + expect(installedPlugins).toHaveLength(0); + expect(namespaces).toHaveLength(0); + expect(migrations).toHaveLength(0); + expect(schemaRows).toHaveLength(0); + }); + + it("refreshes persisted manifests from disk before activation", async () => { + const staleManifest = manifest("paperclip.refresh"); + const refreshedManifest: PaperclipPluginManifestV1 = { + ...staleManifest, + database: { + ...staleManifest.database!, + coreReadTables: ["companies"], + }, + }; + const namespace = derivePluginDatabaseNamespace(refreshedManifest.id); + const packageRoot = await createInstallablePluginPackage( + refreshedManifest, + ` + CREATE TABLE ${namespace}.company_refs ( + id uuid PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) + ); + `, + ); + const pluginId = await installPluginRecord(staleManifest); + await db + .update(plugins) + .set({ + packagePath: packageRoot, + status: "ready", + }) + .where(eq(plugins.id, pluginId)); + + const workerManager = { + startWorker: vi.fn().mockResolvedValue(undefined), + stopAll: vi.fn().mockResolvedValue(undefined), + }; + const loader = pluginLoader(db, { + enableLocalFilesystem: false, + enableNpmDiscovery: false, + }, { + workerManager, + eventBus: { + forPlugin: vi.fn(() => ({})), + subscriptionCount: vi.fn(() => 0), + }, + jobScheduler: { + registerPlugin: vi.fn().mockResolvedValue(undefined), + stop: vi.fn(), + }, + jobStore: { + syncJobDeclarations: vi.fn().mockResolvedValue(undefined), + }, + toolDispatcher: { + registerPluginTools: vi.fn(), + }, + lifecycleManager: { + markError: vi.fn().mockResolvedValue(undefined), + }, + buildHostHandlers: vi.fn(() => ({})), + instanceInfo: { + instanceId: "test-instance", + hostVersion: "1.0.0", + deploymentMode: "authenticated", + deploymentExposure: "public", + }, + } as never); + + const result = await loader.loadSingle(pluginId); + + expect(result.success).toBe(true); + expect(workerManager.startWorker).toHaveBeenCalledWith( + pluginId, + expect.objectContaining({ + databaseNamespace: namespace, + env: { + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "public", + }, + manifest: expect.objectContaining({ + database: expect.objectContaining({ coreReadTables: ["companies"] }), + }), + }), + ); + const [plugin] = await db + .select() + .from(plugins) + .where(eq(plugins.id, pluginId)); + expect(plugin?.manifestJson.database?.coreReadTables).toEqual(["companies"]); + }); + it("rejects checksum changes for already applied migrations", async () => { const pluginManifest = manifest(); const namespace = derivePluginDatabaseNamespace(pluginManifest.id); diff --git a/server/src/__tests__/plugin-local-folders.test.ts b/server/src/__tests__/plugin-local-folders.test.ts new file mode 100644 index 00000000000..89e00eff966 --- /dev/null +++ b/server/src/__tests__/plugin-local-folders.test.ts @@ -0,0 +1,263 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { promises as fs } from "node:fs"; +import { + assertConfiguredLocalFolder, + assertWritableConfiguredLocalFolder, + inspectPluginLocalFolder, + listPluginLocalFolderEntries, + preparePluginLocalFolder, + readPluginLocalFolderText, + resolvePluginLocalFolderPath, + writePluginLocalFolderTextAtomic, +} from "../services/plugin-local-folders.js"; + +describe("plugin local folders", () => { + const tempRoots: string[] = []; + + afterEach(async () => { + await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); + tempRoots.length = 0; + }); + + async function makeRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-folder-")); + tempRoots.push(root); + return root; + } + + it("reports a healthy generic folder when required paths exist", async () => { + const root = await makeRoot(); + await fs.mkdir(path.join(root, "sources")); + await fs.writeFile(path.join(root, "schema.md"), "schema", "utf8"); + + const status = await inspectPluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: root, + access: "readWrite", + requiredDirectories: ["sources"], + requiredFiles: ["schema.md"], + }, + }); + + expect(status.healthy).toBe(true); + expect(status.problems).toEqual([]); + expect(status.requiredDirectories).toEqual(["sources"]); + expect(status.requiredFiles).toEqual(["schema.md"]); + }); + + it("reports missing required folders and files without using product-specific branches", async () => { + const root = await makeRoot(); + + const status = await inspectPluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: root, + requiredDirectories: ["sources"], + requiredFiles: ["schema.md"], + }, + }); + + expect(status.healthy).toBe(false); + expect(status.missingDirectories).toEqual(["sources"]); + expect(status.missingFiles).toEqual(["schema.md"]); + expect(status.problems.map((item) => item.code)).toEqual( + expect.arrayContaining(["missing_directory", "missing_file"]), + ); + }); + + it("reports all required paths as missing when the configured root does not exist", async () => { + const root = await makeRoot(); + const missingRoot = path.join(root, "missing-root"); + + const status = await inspectPluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: missingRoot, + requiredDirectories: ["sources"], + requiredFiles: ["schema.md"], + }, + }); + + expect(status.healthy).toBe(false); + expect(status.configured).toBe(true); + expect(status.readable).toBe(false); + expect(status.missingDirectories).toEqual(["sources"]); + expect(status.missingFiles).toEqual(["schema.md"]); + expect(status.problems.map((item) => item.code)).toContain("missing"); + }); + + it("uses manifest declaration access and required paths over stored or caller overrides", async () => { + const root = await makeRoot(); + await fs.mkdir(path.join(root, "manifest-dir")); + await fs.writeFile(path.join(root, "manifest.md"), "schema", "utf8"); + + const status = await inspectPluginLocalFolder({ + folderKey: "content-root", + declaration: { + folderKey: "content-root", + displayName: "Content root", + access: "read", + requiredDirectories: ["manifest-dir"], + requiredFiles: ["manifest.md"], + }, + storedConfig: { + path: root, + access: "readWrite", + requiredDirectories: ["stored-dir"], + requiredFiles: ["stored.md"], + }, + overrideConfig: { + access: "readWrite", + requiredDirectories: ["override-dir"], + requiredFiles: ["override.md"], + }, + }); + + expect(status.access).toBe("read"); + expect(status.writable).toBe(false); + expect(status.requiredDirectories).toEqual(["manifest-dir"]); + expect(status.requiredFiles).toEqual(["manifest.md"]); + expect(status.healthy).toBe(true); + }); + + it("prepares required directories for a read-write folder without creating required files", async () => { + const root = await makeRoot(); + + await preparePluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: root, + access: "readWrite", + requiredDirectories: ["sources", "wiki/concepts"], + requiredFiles: ["schema.md"], + }, + }); + + await expect(fs.stat(path.join(root, "sources"))).resolves.toMatchObject({}); + await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({}); + await expect(fs.stat(path.join(root, "schema.md"))).rejects.toMatchObject({ code: "ENOENT" }); + + const status = await inspectPluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: root, + access: "readWrite", + requiredDirectories: ["sources", "wiki/concepts"], + requiredFiles: ["schema.md"], + }, + }); + expect(status.missingDirectories).toEqual([]); + expect(status.missingFiles).toEqual(["schema.md"]); + }); + + it("allows write access to repair folders that are only missing required paths", async () => { + const root = await makeRoot(); + const status = await inspectPluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: root, + access: "readWrite", + requiredFiles: ["schema.md"], + }, + }); + + expect(status.healthy).toBe(false); + expect(() => assertConfiguredLocalFolder(status)).toThrow("Local folder is not healthy"); + expect(() => assertWritableConfiguredLocalFolder(status)).not.toThrow(); + + await writePluginLocalFolderTextAtomic(root, "schema.md", "schema"); + const repaired = await inspectPluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: root, + access: "readWrite", + requiredFiles: ["schema.md"], + }, + }); + expect(repaired.healthy).toBe(true); + }); + + it("rejects traversal outside the configured folder", async () => { + const root = await makeRoot(); + + await expect(resolvePluginLocalFolderPath(root, "../outside.txt")).rejects.toMatchObject({ + status: 403, + }); + }); + + it("detects required symlinks that escape the configured folder", async () => { + const root = await makeRoot(); + const outside = await makeRoot(); + await fs.writeFile(path.join(outside, "secret.txt"), "nope", "utf8"); + await fs.symlink(path.join(outside, "secret.txt"), path.join(root, "linked.txt")); + + const status = await inspectPluginLocalFolder({ + folderKey: "content-root", + storedConfig: { + path: root, + requiredFiles: ["linked.txt"], + }, + }); + + expect(status.healthy).toBe(false); + expect(status.problems.some((item) => item.code === "symlink_escape")).toBe(true); + }); + + it("writes files atomically under the root and can read them back", async () => { + const root = await makeRoot(); + await fs.mkdir(path.join(root, "nested")); + + await writePluginLocalFolderTextAtomic(root, "nested/page.md", "hello"); + await writePluginLocalFolderTextAtomic(root, "nested/page.md", "updated"); + + await expect(readPluginLocalFolderText(root, "nested/page.md")).resolves.toBe("updated"); + const leftovers = await fs.readdir(path.join(root, "nested")); + expect(leftovers.filter((name) => name.includes(".paperclip-"))).toEqual([]); + }); + + it("lists nested local folder entries without following symlink escapes", async () => { + const root = await makeRoot(); + const outside = await makeRoot(); + await fs.mkdir(path.join(root, "wiki/concepts"), { recursive: true }); + await fs.writeFile(path.join(root, "wiki/concepts/live.md"), "# Live\n", "utf8"); + await fs.writeFile(path.join(outside, "secret.md"), "# Secret\n", "utf8"); + await fs.symlink(outside, path.join(root, "wiki/outside")); + + const listing = await listPluginLocalFolderEntries(root, { + relativePath: "wiki", + recursive: true, + maxEntries: 20, + }); + + expect(listing.entries.map((entry) => entry.path)).toContain("wiki/concepts/live.md"); + expect(listing.entries.map((entry) => entry.path)).not.toContain("wiki/outside/secret.md"); + expect(listing.truncated).toBe(false); + }); + + it("revalidates temp-file containment before writing atomic contents", async () => { + const root = await makeRoot(); + const outside = await makeRoot(); + const nested = path.join(root, "nested"); + await fs.mkdir(nested); + const originalOpen = fs.open.bind(fs); + const openSpy = vi.spyOn(fs, "open"); + openSpy.mockImplementationOnce(async (file, flags, mode) => { + await fs.rm(nested, { recursive: true, force: true }); + await fs.symlink(outside, nested); + return originalOpen(file, flags, mode); + }); + + try { + await expect(writePluginLocalFolderTextAtomic(root, "nested/page.md", "secret")).rejects.toMatchObject({ + status: 403, + }); + await expect(fs.readFile(path.join(outside, "page.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); + expect(await fs.readdir(outside)).toEqual([]); + } finally { + openSpy.mockRestore(); + } + }); +}); diff --git a/server/src/__tests__/plugin-managed-agents.test.ts b/server/src/__tests__/plugin-managed-agents.test.ts new file mode 100644 index 00000000000..ed512a8d65a --- /dev/null +++ b/server/src/__tests__/plugin-managed-agents.test.ts @@ -0,0 +1,365 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agentConfigRevisions, + agents, + approvals, + companies, + createDb, + pluginEntities, + pluginCompanySettings, + pluginManagedResources, + plugins, +} from "@paperclipai/db"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { buildHostServices } from "../services/plugin-host-services.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: async () => {}, + subscribe: () => {}, + }; + }, + } as any; +} + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +function manifest(): PaperclipPluginManifestV1 { + return { + id: "paperclip.managed-agents-test", + apiVersion: 1, + version: "0.1.0", + displayName: "Managed Agents Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["agents.managed"], + entrypoints: { worker: "./dist/worker.js" }, + agents: [ + { + agentKey: "wiki-maintainer", + displayName: "Wiki Maintainer", + role: "engineer", + title: "Maintains plugin-owned knowledge", + capabilities: "Maintains a plugin-owned wiki.", + adapterType: "process", + adapterConfig: { command: "pnpm wiki:maintain" }, + runtimeConfig: { modelProfiles: { cheap: { enabled: true, adapterConfig: { model: "small" } } } }, + permissions: { canCreateAgents: false }, + budgetMonthlyCents: 1234, + }, + ], + }; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres plugin-managed agent tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("plugin-managed agents", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-agents-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(agentConfigRevisions); + await db.delete(activityLog); + await db.delete(pluginEntities); + await db.delete(pluginManagedResources); + await db.delete(pluginCompanySettings); + await db.delete(approvals); + await db.delete(agents); + await db.delete(plugins); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompanyAndPlugin(options: { requireApproval?: boolean; manifest?: PaperclipPluginManifestV1 } = {}) { + const companyId = randomUUID(); + const pluginId = randomUUID(); + const pluginManifest = options.manifest ?? manifest(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: issuePrefix(companyId), + requireBoardApprovalForNewAgents: options.requireApproval ?? false, + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: pluginManifest.id, + packageName: "@paperclipai/plugin-managed-agents-test", + version: pluginManifest.version, + apiVersion: pluginManifest.apiVersion, + categories: pluginManifest.categories, + manifestJson: pluginManifest, + status: "ready", + installOrder: 1, + }); + const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, { + manifest: pluginManifest, + }); + return { companyId, pluginId, pluginManifest, services }; + } + + it("creates and resolves managed agents by stable resource key", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + + const created = await services.agents.managedReconcile({ + companyId, + agentKey: "wiki-maintainer", + }); + + expect(created.status).toBe("created"); + expect(created.agentId).toBeTruthy(); + expect(created.agent).toMatchObject({ + name: "Wiki Maintainer", + role: "engineer", + adapterConfig: { command: "pnpm wiki:maintain" }, + }); + + const resolved = await services.agents.managedGet({ + companyId, + agentKey: "wiki-maintainer", + }); + expect(resolved.status).toBe("resolved"); + expect(resolved.agentId).toBe(created.agentId); + + const [binding] = await db.select().from(pluginEntities); + expect(binding?.entityType).toBe("managed_agent"); + expect(binding?.scopeKind).toBe("company"); + expect(binding?.scopeId).toBe(companyId); + expect(binding?.data).toMatchObject({ + resourceKind: "agent", + resourceKey: "wiki-maintainer", + agentId: created.agentId, + }); + }); + + it("preserves user edits during reconcile and resets only on explicit reset", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + expect(created.agentId).toBeTruthy(); + + await db + .update(agents) + .set({ + name: "Knowledge Lead", + adapterConfig: { command: "custom" }, + updatedAt: new Date(), + }) + .where(eq(agents.id, created.agentId!)); + + const reconciled = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + expect(reconciled.status).toBe("resolved"); + expect(reconciled.agent).toMatchObject({ + name: "Knowledge Lead", + adapterConfig: { command: "custom" }, + }); + + const reset = await services.agents.managedReset({ companyId, agentKey: "wiki-maintainer" }); + expect(reset.status).toBe("reset"); + expect(reset.agent).toMatchObject({ + name: "Wiki Maintainer", + adapterConfig: { command: "pnpm wiki:maintain" }, + }); + }); + + it("creates managed agents with the most-used compatible company adapter", async () => { + const pluginManifest = manifest(); + pluginManifest.agents![0] = { + ...pluginManifest.agents![0]!, + adapterType: "claude_local", + adapterPreference: ["claude_local", "codex_local"], + adapterConfig: {}, + }; + const { companyId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest }); + await db.insert(agents).values([ + { + id: randomUUID(), + companyId, + name: "Codex One", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: randomUUID(), + companyId, + name: "Codex Two", + role: "engineer", + status: "idle", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: randomUUID(), + companyId, + name: "Claude One", + role: "engineer", + status: "idle", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + + expect(created.status).toBe("created"); + expect(created.agent?.adapterType).toBe("codex_local"); + }); + + it("materializes declared managed agent instructions with local folder paths", async () => { + const previousHome = process.env.PAPERCLIP_HOME; + const previousInstance = process.env.PAPERCLIP_INSTANCE_ID; + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-home-")); + const wikiRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-agent-wiki-"))); + process.env.PAPERCLIP_HOME = tempHome; + process.env.PAPERCLIP_INSTANCE_ID = "test"; + try { + const pluginManifest = manifest(); + pluginManifest.localFolders = [ + { + folderKey: "wiki-root", + displayName: "Wiki root", + access: "readWrite", + requiredDirectories: [], + requiredFiles: ["AGENTS.md"], + }, + ]; + pluginManifest.agents![0] = { + ...pluginManifest.agents![0]!, + adapterType: "claude_local", + adapterConfig: {}, + instructions: { + entryFile: "AGENTS.md", + content: [ + "# LLM Wiki Maintainer", + "", + "You are the LLM Wiki Maintainer.", + "Wiki root: `{{localFolders.wiki-root.path}}`", + "Wiki schema: `{{localFolders.wiki-root.agentsPath}}`", + "", + ].join("\n"), + }, + }; + const { companyId, pluginId, services } = await seedCompanyAndPlugin({ manifest: pluginManifest }); + await fs.writeFile(path.join(wikiRoot, "AGENTS.md"), "# Wiki schema\n", "utf8"); + await db.insert(pluginCompanySettings).values({ + companyId, + pluginId, + enabled: true, + settingsJson: { + localFolders: { + "wiki-root": { + path: wikiRoot, + access: "readWrite", + requiredDirectories: [], + requiredFiles: ["AGENTS.md"], + }, + }, + }, + }); + + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + + const instructionsFilePath = created.agent?.adapterConfig.instructionsFilePath; + expect(typeof instructionsFilePath).toBe("string"); + const content = await fs.readFile(instructionsFilePath as string, "utf8"); + expect(content).toContain("You are the LLM Wiki Maintainer."); + expect(content).toContain(`Wiki root: \`${wikiRoot}\``); + expect(content).toContain(`Wiki schema: \`${path.join(wikiRoot, "AGENTS.md")}\``); + } finally { + if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousHome; + if (previousInstance === undefined) delete process.env.PAPERCLIP_INSTANCE_ID; + else process.env.PAPERCLIP_INSTANCE_ID = previousInstance; + await fs.rm(tempHome, { recursive: true, force: true }); + await fs.rm(wikiRoot, { recursive: true, force: true }); + } + }); + + it("repairs a missing binding by relinking a same-company managed agent marker", async () => { + const { companyId, pluginId, pluginManifest, services } = await seedCompanyAndPlugin(); + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Renamed Wiki Agent", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: { command: "custom" }, + runtimeConfig: {}, + permissions: {}, + metadata: { + paperclipManagedResource: { + pluginId, + pluginKey: pluginManifest.id, + resourceKind: "agent", + resourceKey: "wiki-maintainer", + }, + }, + }); + + const relinked = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + expect(relinked.status).toBe("relinked"); + expect(relinked.agentId).toBe(agentId); + + const [binding] = await db.select().from(pluginEntities); + expect(binding?.data).toMatchObject({ agentId }); + }); + + it("respects board approval policy for new managed agents", async () => { + const { companyId, services } = await seedCompanyAndPlugin({ requireApproval: true }); + + const created = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + + expect(created.status).toBe("created"); + expect(created.agent?.status).toBe("pending_approval"); + expect(created.approvalId).toBeTruthy(); + + const [approval] = await db.select().from(approvals).where(eq(approvals.id, created.approvalId!)); + expect(approval).toMatchObject({ + type: "hire_agent", + status: "pending", + }); + expect(approval?.payload).toMatchObject({ + agentId: created.agentId, + sourcePluginKey: "paperclip.managed-agents-test", + managedResourceKey: "wiki-maintainer", + }); + }); +}); diff --git a/server/src/__tests__/plugin-managed-routines.test.ts b/server/src/__tests__/plugin-managed-routines.test.ts new file mode 100644 index 00000000000..6f89cac17ae --- /dev/null +++ b/server/src/__tests__/plugin-managed-routines.test.ts @@ -0,0 +1,249 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { + activityLog, + agentConfigRevisions, + agents, + companies, + createDb, + issues, + pluginManagedResources, + plugins, + projects, + routineRuns, + routineTriggers, + routines, +} from "@paperclipai/db"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { buildHostServices } from "../services/plugin-host-services.js"; +import { routineService } from "../services/routines.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +function createEventBusStub() { + return { + forPlugin() { + return { + emit: async () => {}, + subscribe: () => {}, + }; + }, + } as any; +} + +function issuePrefix(id: string) { + return `T${id.replace(/-/g, "").slice(0, 6).toUpperCase()}`; +} + +function manifest(): PaperclipPluginManifestV1 { + return { + id: "paperclip.managed-routines-test", + apiVersion: 1, + version: "0.1.0", + displayName: "Managed Routines Test", + description: "Test plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["agents.managed", "projects.managed", "routines.managed"], + entrypoints: { worker: "./dist/worker.js" }, + agents: [{ + agentKey: "wiki-maintainer", + displayName: "Wiki Maintainer", + role: "engineer", + adapterType: "process", + adapterConfig: { command: "pnpm wiki:maintain" }, + }], + projects: [{ + projectKey: "operations", + displayName: "Plugin Operations", + description: "Plugin operation inspection", + status: "in_progress", + }], + routines: [{ + routineKey: "nightly-lint", + title: "Nightly lint", + description: "Lint plugin state", + assigneeRef: { resourceKind: "agent", resourceKey: "wiki-maintainer" }, + projectRef: { resourceKind: "project", resourceKey: "operations" }, + status: "active", + priority: "medium", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + triggers: [{ + kind: "schedule", + label: "Nightly", + cronExpression: "0 3 * * *", + timezone: "UTC", + }], + issueTemplate: { + surfaceVisibility: "plugin_operation", + originId: "operation:nightly-lint", + billingCode: "plugin-test:nightly-lint", + }, + }], + }; +} + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres plugin-managed routine tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("plugin-managed routines", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-managed-routines-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(routineRuns); + await db.delete(routineTriggers); + await db.delete(routines); + await db.delete(issues); + await db.delete(agentConfigRevisions); + await db.delete(activityLog); + await db.delete(pluginManagedResources); + await db.delete(agents); + await db.delete(projects); + await db.delete(plugins); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function seedCompanyAndPlugin(pluginManifest = manifest()) { + const companyId = randomUUID(); + const pluginId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: issuePrefix(companyId), + }); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: pluginManifest.id, + packageName: "@paperclipai/plugin-managed-routines-test", + version: pluginManifest.version, + apiVersion: pluginManifest.apiVersion, + categories: pluginManifest.categories, + manifestJson: pluginManifest, + status: "ready", + installOrder: 1, + }); + const services = buildHostServices(db, pluginId, pluginManifest.id, createEventBusStub(), undefined, { + manifest: pluginManifest, + }); + return { companyId, pluginId, pluginManifest, services }; + } + + it("resolves routine agent and project refs by stable managed keys", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" }); + + const created = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" }); + + expect(created.status).toBe("created"); + expect(created.routine).toMatchObject({ + title: "Nightly lint", + assigneeAgentId: agent.agentId, + projectId: project.projectId, + managedByPlugin: expect.objectContaining({ + pluginKey: "paperclip.managed-routines-test", + resourceKind: "routine", + resourceKey: "nightly-lint", + }), + }); + + const [trigger] = await db.select().from(routineTriggers).where(eq(routineTriggers.routineId, created.routineId!)); + expect(trigger).toMatchObject({ + kind: "schedule", + cronExpression: "0 3 * * *", + timezone: "UTC", + }); + }); + + it("returns missing refs until the operator repairs them and preserves routine edits on reconcile", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + + const missing = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" }); + expect(missing.status).toBe("missing_refs"); + expect(missing.missingRefs).toEqual([ + expect.objectContaining({ resourceKind: "agent", resourceKey: "wiki-maintainer" }), + expect.objectContaining({ resourceKind: "project", resourceKey: "operations" }), + ]); + + const [agent] = await db.insert(agents).values({ + companyId, + name: "Operator-selected maintainer", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }).returning(); + const [project] = await db.insert(projects).values({ + companyId, + name: "Operator-selected project", + status: "in_progress", + }).returning(); + + const repaired = await services.routines.managedReconcile({ + companyId, + routineKey: "nightly-lint", + assigneeAgentId: agent.id, + projectId: project.id, + }); + expect(repaired.status).toBe("created"); + expect(repaired.routine).toMatchObject({ + assigneeAgentId: agent.id, + projectId: project.id, + }); + + await db + .update(routines) + .set({ title: "Operator renamed lint", updatedAt: new Date() }) + .where(eq(routines.id, repaired.routineId!)); + + const reconciled = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" }); + expect(reconciled.status).toBe("resolved"); + expect(reconciled.routine?.title).toBe("Operator renamed lint"); + }); + + it("creates routine operation issues with plugin visibility and managed project scoping", async () => { + const { companyId, services } = await seedCompanyAndPlugin(); + const agent = await services.agents.managedReconcile({ companyId, agentKey: "wiki-maintainer" }); + const project = await services.projects.reconcileManaged({ companyId, projectKey: "operations" }); + const routine = await services.routines.managedReconcile({ companyId, routineKey: "nightly-lint" }); + const wakeup = vi.fn(async () => ({ id: randomUUID() })); + const routinesSvc = routineService(db, { heartbeat: { wakeup } }); + + const run = await routinesSvc.runRoutine(routine.routineId!, { source: "manual" }, { userId: "board-user" }); + + expect(run.status).toBe("issue_created"); + const [issue] = await db.select().from(issues).where(eq(issues.id, run.linkedIssueId!)); + expect(issue).toMatchObject({ + originKind: "plugin:paperclip.managed-routines-test:operation", + originId: "operation:nightly-lint", + billingCode: "plugin-test:nightly-lint", + projectId: project.projectId, + assigneeAgentId: agent.agentId, + }); + expect(wakeup).toHaveBeenCalledWith(agent.agentId, expect.objectContaining({ + reason: "issue_assigned", + })); + }); +}); diff --git a/server/src/__tests__/plugin-orchestration-apis.test.ts b/server/src/__tests__/plugin-orchestration-apis.test.ts index b8ee02e962f..402419c68b8 100644 --- a/server/src/__tests__/plugin-orchestration-apis.test.ts +++ b/server/src/__tests__/plugin-orchestration-apis.test.ts @@ -1,4 +1,7 @@ import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { @@ -11,6 +14,9 @@ import { heartbeatRuns, issueRelations, issues, + pluginManagedResources, + plugins, + projects, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -45,6 +51,7 @@ if (!embeddedPostgresSupport.supported) { describeEmbeddedPostgres("plugin orchestration APIs", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; + const tempRoots: string[] = []; beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plugin-orchestration-"); @@ -52,12 +59,17 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { }, 20_000); afterEach(async () => { + await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true }))); + tempRoots.length = 0; await db.delete(activityLog); await db.delete(costEvents); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(issueRelations); await db.delete(issues); + await db.delete(pluginManagedResources); + await db.delete(projects); + await db.delete(plugins); await db.delete(agents); await db.delete(companies); }); @@ -89,6 +101,12 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { return { companyId, agentId }; } + async function makeLocalRoot() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-host-folder-")); + tempRoots.push(root); + return root; + } + it("creates plugin-origin issues with full orchestration fields and audit activity", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const blockerIssueId = randomUUID(); @@ -189,6 +207,293 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => { ).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.missions"); }); + it("creates plugin operation issues with the generic operation origin", async () => { + const { companyId } = await seedCompanyAndAgent(); + const services = buildHostServices(db, "plugin-record-id", "paperclip.missions", createEventBusStub()); + + const issue = await services.issues.create({ + companyId, + title: "Background operation", + surfaceVisibility: "plugin_operation", + originId: "mission-alpha:operation-1", + }); + + expect(issue.originKind).toBe("plugin:paperclip.missions:operation"); + expect(issue.originId).toBe("mission-alpha:operation-1"); + }); + + it("lets bootstrap-style actions initialize required local folders from an empty root", async () => { + const { companyId } = await seedCompanyAndAgent(); + const pluginId = randomUUID(); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "paperclipai.plugin-llm-wiki", + packageName: "@paperclipai/plugin-llm-wiki", + version: "0.1.0", + manifestJson: { + id: "paperclipai.plugin-llm-wiki", + apiVersion: 1, + version: "0.1.0", + displayName: "LLM Wiki", + description: "Local-file LLM Wiki plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["local.folders"], + entrypoints: { worker: "./dist/worker.js" }, + localFolders: [ + { + folderKey: "wiki-root", + displayName: "Wiki root", + access: "readWrite", + requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"], + requiredFiles: ["WIKI.md", "AGENTS.md"], + }, + ], + }, + status: "ready", + }); + const root = await makeLocalRoot(); + const services = buildHostServices( + db, + pluginId, + "paperclipai.plugin-llm-wiki", + createEventBusStub(), + undefined, + { + manifest: { + id: "paperclipai.plugin-llm-wiki", + apiVersion: 1, + version: "0.1.0", + displayName: "LLM Wiki", + description: "Local-file LLM Wiki plugin", + author: "Paperclip", + categories: ["automation"], + capabilities: ["local.folders"], + entrypoints: { worker: "./dist/worker.js" }, + localFolders: [ + { + folderKey: "wiki-root", + displayName: "Wiki root", + access: "readWrite", + requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"], + requiredFiles: ["WIKI.md", "AGENTS.md"], + }, + ], + }, + }, + ); + + const configured = await services.localFolders.configure({ + companyId, + folderKey: "wiki-root", + path: root, + access: "readWrite", + requiredDirectories: ["raw", "wiki", "wiki/concepts", ".paperclip"], + requiredFiles: ["WIKI.md", "AGENTS.md"], + }); + expect(configured.healthy).toBe(false); + expect(configured.missingDirectories).toEqual([]); + expect(configured.missingFiles).toEqual(["WIKI.md", "AGENTS.md"]); + + await fs.rm(path.join(root, "raw"), { recursive: true, force: true }); + await fs.rm(path.join(root, "wiki"), { recursive: true, force: true }); + await expect(services.localFolders.readText({ companyId, folderKey: "wiki-root", relativePath: "WIKI.md" })) + .rejects.toThrow("Local folder is not healthy"); + await services.localFolders.writeTextAtomic({ + companyId, + folderKey: "wiki-root", + relativePath: "WIKI.md", + contents: "# Wiki\n", + }); + await services.localFolders.writeTextAtomic({ + companyId, + folderKey: "wiki-root", + relativePath: "AGENTS.md", + contents: "# Agents\n", + }); + + const finalStatus = await services.localFolders.status({ companyId, folderKey: "wiki-root" }); + expect(finalStatus.healthy).toBe(true); + await expect(fs.stat(path.join(root, "raw"))).resolves.toMatchObject({}); + await expect(fs.stat(path.join(root, "wiki/concepts"))).resolves.toMatchObject({}); + await expect(fs.readFile(path.join(root, "WIKI.md"), "utf8")).resolves.toBe("# Wiki\n"); + }); + + it("rejects worker local-folder access for undeclared manifest keys", async () => { + const { companyId } = await seedCompanyAndAgent(); + const pluginId = randomUUID(); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "paperclip.local-folders", + packageName: "@paperclip/plugin-local-folders", + version: "0.1.0", + manifestJson: { + id: "paperclip.local-folders", + apiVersion: 1, + version: "0.1.0", + displayName: "Local Folders", + description: "Local folder fixture", + author: "Paperclip", + categories: ["automation"], + capabilities: ["local.folders"], + entrypoints: { worker: "./dist/worker.js" }, + localFolders: [ + { + folderKey: "content-root", + displayName: "Content root", + access: "readWrite", + }, + ], + }, + status: "ready", + }); + const services = buildHostServices( + db, + pluginId, + "paperclip.local-folders", + createEventBusStub(), + undefined, + { + manifest: { + id: "paperclip.local-folders", + apiVersion: 1, + version: "0.1.0", + displayName: "Local Folders", + description: "Local folder fixture", + author: "Paperclip", + categories: ["automation"], + capabilities: ["local.folders"], + entrypoints: { worker: "./dist/worker.js" }, + localFolders: [ + { + folderKey: "content-root", + displayName: "Content root", + access: "readWrite", + }, + ], + }, + }, + ); + await expect(services.localFolders.configure({ + companyId, + folderKey: "ssh", + path: "/tmp", + access: "read", + })).rejects.toThrow("Local folder key is not declared"); + await expect(services.localFolders.status({ companyId, folderKey: "ssh" })) + .rejects.toThrow("Local folder key is not declared"); + await expect(services.localFolders.readText({ companyId, folderKey: "ssh", relativePath: "id_rsa" })) + .rejects.toThrow("Local folder key is not declared"); + await expect(services.localFolders.writeTextAtomic({ + companyId, + folderKey: "ssh", + relativePath: "id_rsa", + contents: "secret", + })).rejects.toThrow("Local folder key is not declared"); + }); + + it("resolves plugin-managed projects by stable key without overwriting user edits", async () => { + const { companyId } = await seedCompanyAndAgent(); + const pluginId = randomUUID(); + await db.insert(plugins).values({ + id: pluginId, + pluginKey: "paperclip.missions", + packageName: "@paperclip/plugin-missions", + version: "0.1.0", + apiVersion: 1, + categories: ["automation"], + status: "ready", + manifestJson: { + id: "paperclip.missions", + apiVersion: 1, + version: "0.1.0", + displayName: "Missions", + description: "Mission orchestration", + author: "Paperclip", + categories: ["automation"], + capabilities: ["projects.managed"], + entrypoints: { worker: "./dist/worker.js" }, + projects: [{ + projectKey: "operations", + displayName: "Mission Operations", + description: "Plugin operation inspection area", + status: "in_progress", + color: "#14b8a6", + settings: { surface: "operations" }, + }], + }, + }); + + const services = buildHostServices(db, pluginId, "paperclip.missions", createEventBusStub()); + const missing = await services.projects.getManaged({ companyId, projectKey: "operations" }); + expect(missing.status).toBe("missing"); + expect(missing.projectId).toBeNull(); + await expect( + db + .select() + .from(pluginManagedResources) + .where(and( + eq(pluginManagedResources.companyId, companyId), + eq(pluginManagedResources.pluginId, pluginId), + eq(pluginManagedResources.resourceKind, "project"), + eq(pluginManagedResources.resourceKey, "operations"), + )), + ).resolves.toHaveLength(0); + + const created = await services.projects.reconcileManaged({ companyId, projectKey: "operations" }); + + expect(created.status).toBe("created"); + expect(created.projectId).toEqual(expect.any(String)); + expect(created.project?.managedByPlugin).toMatchObject({ + pluginId, + pluginKey: "paperclip.missions", + pluginDisplayName: "Missions", + resourceKind: "project", + resourceKey: "operations", + }); + + await db + .update(projects) + .set({ name: "Renamed by operator", description: "User-owned text", updatedAt: new Date() }) + .where(eq(projects.id, created.projectId!)); + await db + .update(plugins) + .set({ + manifestJson: { + id: "paperclip.missions", + apiVersion: 1, + version: "0.2.0", + displayName: "Missions", + description: "Mission orchestration", + author: "Paperclip", + categories: ["automation"], + capabilities: ["projects.managed"], + entrypoints: { worker: "./dist/worker.js" }, + projects: [{ + projectKey: "operations", + displayName: "Upgraded Default Name", + description: "Upgraded default description", + status: "planned", + color: "#f97316", + settings: { surface: "operations", upgraded: true }, + }], + }, + updatedAt: new Date(), + }) + .where(eq(plugins.id, pluginId)); + + const resolved = await services.projects.reconcileManaged({ companyId, projectKey: "operations" }); + + expect(resolved.status).toBe("resolved"); + expect(resolved.projectId).toBe(created.projectId); + expect(resolved.project?.name).toBe("Renamed by operator"); + expect(resolved.project?.description).toBe("User-owned text"); + expect(resolved.project?.managedByPlugin?.defaultsJson).toMatchObject({ + displayName: "Upgraded Default Name", + settings: { upgraded: true }, + }); + }); + it("asserts checkout ownership for run-scoped plugin actions", async () => { const { companyId, agentId } = await seedCompanyAndAgent(); const issueId = randomUUID(); diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index df41f7ff529..faf3487bf68 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -6,6 +6,8 @@ const mockRegistry = vi.hoisted(() => ({ getById: vi.fn(), getByKey: vi.fn(), upsertConfig: vi.fn(), + getCompanySettings: vi.fn(), + upsertCompanySettings: vi.fn(), })); const mockLifecycle = vi.hoisted(() => ({ @@ -317,6 +319,61 @@ describe.sequential("scoped plugin API routes", () => { }, 20_000); }); +describe.sequential("plugin local folder routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRegistry.getCompanySettings.mockResolvedValue(null); + }); + + function readyLocalFolderPlugin() { + mockRegistry.getById.mockResolvedValue({ + id: pluginId, + pluginKey: "paperclip.example", + version: "1.0.0", + status: "ready", + manifestJson: { + id: "paperclip.example", + capabilities: ["local.folders"], + localFolders: [ + { + folderKey: "content-root", + displayName: "Content root", + access: "readWrite", + requiredDirectories: ["docs"], + requiredFiles: ["README.md"], + }, + ], + }, + }); + } + + it("rejects validation for undeclared local folder keys", async () => { + readyLocalFolderPlugin(); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh/validate`) + .send({ path: "/tmp" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("Local folder key is not declared"); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); + + it("rejects saving undeclared local folder keys", async () => { + readyLocalFolderPlugin(); + const { app } = await createApp(boardActor()); + + const res = await request(app) + .put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/ssh`) + .send({ path: "/tmp" }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("Local folder key is not declared"); + expect(mockRegistry.upsertCompanySettings).not.toHaveBeenCalled(); + }); +}); + describe.sequential("plugin tool and bridge authz", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/server/src/__tests__/plugin-scoped-api-routes.test.ts b/server/src/__tests__/plugin-scoped-api-routes.test.ts index 1dab1edfad7..4e2ce297bc0 100644 --- a/server/src/__tests__/plugin-scoped-api-routes.test.ts +++ b/server/src/__tests__/plugin-scoped-api-routes.test.ts @@ -98,6 +98,7 @@ describe.sequential("plugin scoped API routes", () => { const pluginId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; const agentId = "33333333-3333-4333-8333-333333333333"; + const peerAgentId = "33333333-3333-4333-8333-333333333334"; const runId = "44444444-4444-4444-8444-444444444444"; const issueId = "55555555-5555-4555-8555-555555555555"; @@ -252,6 +253,55 @@ describe.sequential("plugin scoped API routes", () => { })); }); + it("allows non-assignee agents on in-progress required checkout routes without claiming checkout ownership", async () => { + const apiRoutes = manifest([ + { + routeKey: "issue.advance", + method: "POST", + path: "/issues/:issueId/advance", + auth: "agent", + capability: "api.routes.register", + checkoutPolicy: "required-for-agent-in-progress", + companyResolution: { from: "issue", param: "issueId" }, + }, + ]); + mockIssueService.getById.mockResolvedValue({ + id: issueId, + companyId, + status: "in_progress", + assigneeAgentId: agentId, + }); + const { app, workerManager } = await createApp({ + actor: { + type: "agent", + agentId: peerAgentId, + companyId, + runId, + source: "agent_key", + }, + plugin: { + id: pluginId, + pluginKey: apiRoutes.id, + status: "ready", + manifestJson: apiRoutes, + }, + }); + + const res = await request(app) + .post(`/api/plugins/${pluginId}/api/issues/${issueId}/advance`) + .send({ step: "next" }); + + expect(res.status).toBe(200); + expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled(); + expect(workerManager.call).toHaveBeenCalledWith(pluginId, "handleApiRequest", expect.objectContaining({ + routeKey: "issue.advance", + params: { issueId }, + body: { step: "next" }, + actor: expect.objectContaining({ actorType: "agent", agentId: peerAgentId, runId }), + companyId, + })); + }); + it("rejects checkout-protected agent routes without a run id before worker dispatch", async () => { const apiRoutes = manifest([ { diff --git a/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts b/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts index d68cb60a534..957262d8fec 100644 --- a/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts +++ b/server/src/__tests__/plugin-sdk-orchestration-contract.test.ts @@ -150,6 +150,23 @@ describe("plugin SDK orchestration contract", () => { ).rejects.toThrow("Plugin may only use originKind values under plugin:paperclip.test-orchestration"); }); + it("supports generic plugin operation issue visibility in the test harness", async () => { + const companyId = randomUUID(); + const harness = createTestHarness({ + manifest: manifest(["issues.create"]), + }); + + const created = await harness.ctx.issues.create({ + companyId, + title: "Background operation", + surfaceVisibility: "plugin_operation", + originId: "operation-1", + }); + + expect(created.originKind).toBe("plugin:paperclip.test-orchestration:operation"); + expect(created.originId).toBe("operation-1"); + }); + it("enforces checkout and wakeup capabilities in the test harness", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/app.ts b/server/src/app.ts index 6b27357301d..d94475afeab 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -253,6 +253,8 @@ export async function createApp( instanceInfo: { instanceId: opts.instanceId ?? "default", hostVersion: opts.hostVersion ?? "0.0.0", + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, }, buildHostHandlers: (pluginId, manifest) => { const notifyWorker = (method: string, params: unknown) => { @@ -261,6 +263,7 @@ export async function createApp( }; const services = buildHostServices(db, pluginId, manifest.id, eventBus, notifyWorker, { pluginWorkerManager: workerManager, + manifest, }); hostServicesDisposers.set(pluginId, () => services.dispose()); return createHostClientHandlers({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index f37fbaa83b5..5887df79e0e 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1020,11 +1020,14 @@ export function issueRoutes( descendantOf: req.query.descendantOf as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, + originKindPrefix: req.query.originKindPrefix as string | undefined, originId: req.query.originId as string | undefined, includeRoutineExecutions: req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1", excludeRoutineExecutions: req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1", + includePluginOperations: + req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1", includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1", q: req.query.q as string | undefined, limit, diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 43a6e7dd25e..35b60812609 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -66,6 +66,13 @@ import { getActorInfo, } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; +import { + findLocalFolderDeclaration, + getStoredLocalFolders, + inspectPluginLocalFolder, + requireLocalFolderDeclaration, + setStoredLocalFolder, +} from "../services/plugin-local-folders.js"; import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js"; /** UI slot declaration extracted from plugin manifest */ @@ -2379,6 +2386,152 @@ export function pluginRoutes( } }); + // =========================================================================== + // Company-scoped trusted local folders + // =========================================================================== + + router.get("/plugins/:pluginId/companies/:companyId/local-folders", async (req, res) => { + assertBoardOrgAccess(req); + const { pluginId, companyId } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const settings = await registry.getCompanySettings(plugin.id, companyId); + const storedFolders = getStoredLocalFolders(settings?.settingsJson); + const declarations = plugin.manifestJson.localFolders ?? []; + const folderKeys = declarations.map((declaration) => declaration.folderKey); + + const statuses = await Promise.all(folderKeys.map((folderKey) => + inspectPluginLocalFolder({ + folderKey, + declaration: findLocalFolderDeclaration(declarations, folderKey), + storedConfig: storedFolders[folderKey] ?? null, + }))); + + res.json({ + pluginId: plugin.id, + companyId, + declarations, + folders: statuses, + }); + }); + + router.get("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/status", async (req, res) => { + assertBoardOrgAccess(req); + const { pluginId, companyId, folderKey } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const settings = await registry.getCompanySettings(plugin.id, companyId); + const storedFolders = getStoredLocalFolders(settings?.settingsJson); + const declarations = plugin.manifestJson.localFolders ?? []; + const declaration = requireLocalFolderDeclaration(declarations, folderKey); + const status = await inspectPluginLocalFolder({ + folderKey, + declaration, + storedConfig: storedFolders[folderKey] ?? null, + }); + res.json(status); + }); + + router.post("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey/validate", async (req, res) => { + assertBoardOrgAccess(req); + const { pluginId, companyId, folderKey } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const body = req.body as { + path?: unknown; + access?: "read" | "readWrite"; + requiredDirectories?: string[]; + requiredFiles?: string[]; + } | undefined; + if (typeof body?.path !== "string" || body.path.trim().length === 0) { + res.status(400).json({ error: '"path" is required and must be a non-empty string' }); + return; + } + + const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey); + const status = await inspectPluginLocalFolder({ + folderKey, + declaration, + overrideConfig: { + path: body.path, + }, + }); + res.json(status); + }); + + router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => { + assertBoardOrgAccess(req); + const { pluginId, companyId, folderKey } = req.params; + assertCompanyAccess(req, companyId); + + const plugin = await resolvePlugin(registry, pluginId); + if (!plugin) { + res.status(404).json({ error: "Plugin not found" }); + return; + } + + const body = req.body as { + path?: unknown; + access?: "read" | "readWrite"; + requiredDirectories?: string[]; + requiredFiles?: string[]; + } | undefined; + if (typeof body?.path !== "string" || body.path.trim().length === 0) { + res.status(400).json({ error: '"path" is required and must be a non-empty string' }); + return; + } + + const existing = await registry.getCompanySettings(plugin.id, companyId); + const declaration = requireLocalFolderDeclaration(plugin.manifestJson.localFolders ?? [], folderKey); + const status = await inspectPluginLocalFolder({ + folderKey, + declaration, + storedConfig: getStoredLocalFolders(existing?.settingsJson)[folderKey] ?? null, + overrideConfig: { + path: body.path, + }, + }); + + const nextSettings = setStoredLocalFolder(existing?.settingsJson, folderKey, { + path: body.path, + access: status.access, + requiredDirectories: status.requiredDirectories, + requiredFiles: status.requiredFiles, + }); + await registry.upsertCompanySettings(plugin.id, companyId, { + enabled: existing?.enabled ?? true, + settingsJson: nextSettings, + lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "), + }); + await logPluginMutationActivity(req, "plugin.local_folder.configured", plugin.id, { + pluginId: plugin.id, + pluginKey: plugin.pluginKey, + companyId, + folderKey, + healthy: status.healthy, + }); + + res.json(status); + }); + // =========================================================================== // Plugin health dashboard — aggregated diagnostics for the settings page // =========================================================================== diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 66c83389acc..adb49b33ad6 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer"; -import { and, asc, desc, eq, gt, inArray, isNull, lt, ne, notInArray, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gt, inArray, isNull, like, lt, ne, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, @@ -127,9 +127,11 @@ export interface IssueFilters { descendantOf?: string; labelId?: string; originKind?: string; + originKindPrefix?: string; originId?: string; includeRoutineExecutions?: boolean; excludeRoutineExecutions?: boolean; + includePluginOperations?: boolean; includeBlockedBy?: boolean; q?: string; limit?: number; @@ -563,6 +565,19 @@ function inboxVisibleForUserCondition(companyId: string, userId: string) { `; } +function nonPluginOperationIssueCondition() { + return sql`NOT (${issues.originKind} LIKE 'plugin:%:operation' OR ${issues.originKind} LIKE 'plugin:%:operation:%')`; +} + +function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) { + return Boolean( + filters?.includePluginOperations || + filters?.originKind || + filters?.originId || + filters?.projectId, + ); +} + /** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */ const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly> = { amp: "&", @@ -2201,7 +2216,11 @@ export function issueService(db: Db) { } if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); + if (filters?.originKindPrefix) conditions.push(like(issues.originKind, `${filters.originKindPrefix}%`)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); + if (!shouldIncludePluginOperationIssues(filters)) { + conditions.push(nonPluginOperationIssueCondition()); + } if (filters?.labelId) { const labeledIssueIds = await db .select({ issueId: issueLabels.issueId }) @@ -2333,6 +2352,7 @@ export function issueService(db: Db) { const conditions = [ eq(issues.companyId, companyId), isNull(issues.hiddenAt), + nonPluginOperationIssueCondition(), unreadForUserCondition(companyId, userId), ]; if (status) { diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index e69332ca56d..ef54d61d2a6 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -47,6 +47,12 @@ const OPERATION_CAPABILITIES: Record = { "companies.get": ["companies.read"], "projects.list": ["projects.read"], "projects.get": ["projects.read"], + "projects.managed.get": ["projects.managed"], + "projects.managed.reconcile": ["projects.managed"], + "projects.managed.reset": ["projects.managed"], + "routines.managed.get": ["routines.managed"], + "routines.managed.reconcile": ["routines.managed"], + "routines.managed.reset": ["routines.managed"], "project.workspaces.list": ["project.workspaces.read"], "project.workspaces.get": ["project.workspaces.read"], "issues.list": ["issues.read"], @@ -56,6 +62,9 @@ const OPERATION_CAPABILITIES: Record = { "issue.comments.get": ["issue.comments.read"], "agents.list": ["agents.read"], "agents.get": ["agents.read"], + "agents.managed.get": ["agents.managed"], + "agents.managed.reconcile": ["agents.managed"], + "agents.managed.reset": ["agents.managed"], "goals.list": ["goals.read"], "goals.get": ["goals.read"], "activity.list": ["activity.read"], @@ -65,6 +74,12 @@ const OPERATION_CAPABILITIES: Record = { "issues.summaries.getOrchestration": ["issues.orchestration.read"], "db.namespace": ["database.namespace.read"], "db.query": ["database.namespace.read"], + "localFolders.declarations": [], + "localFolders.configure": ["local.folders"], + "localFolders.status": ["local.folders"], + "localFolders.list": ["local.folders"], + "localFolders.readText": ["local.folders"], + "localFolders.writeTextAtomic": ["local.folders"], // Data write operations "issues.create": ["issues.create"], @@ -133,6 +148,7 @@ const UI_SLOT_CAPABILITIES: Record = { commentAnnotation: "ui.commentAnnotation.register", commentContextMenuItem: "ui.action.register", settingsPage: "instance.settings.register", + routeSidebar: "ui.sidebar.register", }; /** @@ -167,6 +183,9 @@ const FEATURE_CAPABILITIES: Record = { webhooks: "webhooks.receive", database: "database.namespace.migrate", environmentDrivers: "environment.drivers.register", + agents: "agents.managed", + projects: "projects.managed", + routines: "routines.managed", }; // --------------------------------------------------------------------------- diff --git a/server/src/services/plugin-database.ts b/server/src/services/plugin-database.ts index e1822e45cd2..e6811702beb 100644 --- a/server/src/services/plugin-database.ts +++ b/server/src/services/plugin-database.ts @@ -303,7 +303,19 @@ function resolveMigrationsDir(packageRoot: string, migrationsDir: string): strin return resolvedDir; } -export function pluginDatabaseService(db: Db) { +type PluginDatabaseClient = Pick; +type PluginDatabaseRootClient = PluginDatabaseClient & Partial>; + +export interface ApplyPluginMigrationsOptions { + /** + * Persist failed migration ledger rows. Fresh install uses false because the + * caller owns a larger transaction and must roll back the plugin row and + * namespace together. + */ + persistFailure?: boolean; +} + +export function pluginDatabaseService(db: PluginDatabaseRootClient) { async function getPluginRecord(pluginId: string) { const rows = await db.select().from(plugins).where(eq(plugins.id, pluginId)).limit(1); const plugin = rows[0]; @@ -311,14 +323,18 @@ export function pluginDatabaseService(db: Db) { return plugin; } - async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) { + async function ensureNamespaceWithClient( + client: PluginDatabaseClient, + pluginId: string, + manifest: PaperclipPluginManifestV1, + ) { if (!manifest.database) return null; const namespaceName = derivePluginDatabaseNamespace( manifest.id, manifest.database.namespaceSlug, ); - await db.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`)); - const rows = await db + await client.execute(sql.raw(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(namespaceName)}`)); + const rows = await client .insert(pluginDatabaseNamespaces) .values({ pluginId, @@ -341,6 +357,10 @@ export function pluginDatabaseService(db: Db) { return rows[0] ?? null; } + async function ensureNamespace(pluginId: string, manifest: PaperclipPluginManifestV1) { + return ensureNamespaceWithClient(db, pluginId, manifest); + } + async function getNamespace(pluginId: string) { const rows = await db .select() @@ -358,7 +378,7 @@ export function pluginDatabaseService(db: Db) { return namespace.namespaceName; } - async function recordMigrationFailure(input: { + async function recordMigrationFailure(client: PluginDatabaseClient, input: { pluginId: string; pluginKey: string; namespaceName: string; @@ -368,7 +388,7 @@ export function pluginDatabaseService(db: Db) { error: unknown; }): Promise { const message = input.error instanceof Error ? input.error.message : String(input.error); - await db + await client .insert(pluginMigrations) .values({ pluginId: input.pluginId, @@ -391,7 +411,7 @@ export function pluginDatabaseService(db: Db) { appliedAt: null, }, }); - await db + await client .update(pluginDatabaseNamespaces) .set({ status: "migration_failed", updatedAt: new Date() }) .where(eq(pluginDatabaseNamespaces.pluginId, input.pluginId)); @@ -400,7 +420,12 @@ export function pluginDatabaseService(db: Db) { return { ensureNamespace, - async applyMigrations(pluginId: string, manifest: PaperclipPluginManifestV1, packageRoot: string) { + async applyMigrations( + pluginId: string, + manifest: PaperclipPluginManifestV1, + packageRoot: string, + options: ApplyPluginMigrationsOptions = {}, + ) { if (!manifest.database) return null; const namespace = await ensureNamespace(pluginId, manifest); if (!namespace) return null; @@ -409,13 +434,14 @@ export function pluginDatabaseService(db: Db) { const migrationFiles = await listSqlMigrationFiles(migrationDir); const coreReadTables = manifest.database.coreReadTables ?? []; const lockKey = Number.parseInt(createHash("sha256").update(pluginId).digest("hex").slice(0, 12), 16); + const persistFailure = options.persistFailure ?? true; - await db.transaction(async (tx) => { - await tx.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`); + const applyWithClient = async (client: PluginDatabaseClient) => { + await client.execute(sql`SELECT pg_advisory_xact_lock(${lockKey})`); for (const migrationKey of migrationFiles) { const content = await readFile(path.join(migrationDir, migrationKey), "utf8"); const checksum = createHash("sha256").update(content).digest("hex"); - const existingRows = await tx + const existingRows = await client .select() .from(pluginMigrations) .where(and(eq(pluginMigrations.pluginId, pluginId), eq(pluginMigrations.migrationKey, migrationKey))) @@ -435,9 +461,9 @@ export function pluginDatabaseService(db: Db) { } for (const statement of statements) { validatePluginMigrationStatement(statement, namespace.namespaceName, coreReadTables); - await tx.execute(sql.raw(statement)); + await client.execute(sql.raw(statement)); } - await tx + await client .insert(pluginMigrations) .values({ pluginId, @@ -461,19 +487,27 @@ export function pluginDatabaseService(db: Db) { }, }); } catch (error) { - await recordMigrationFailure({ - pluginId, - pluginKey: manifest.id, - namespaceName: namespace.namespaceName, - migrationKey, - checksum, - pluginVersion: manifest.version, - error, - }); + if (persistFailure) { + await recordMigrationFailure(db, { + pluginId, + pluginKey: manifest.id, + namespaceName: namespace.namespaceName, + migrationKey, + checksum, + pluginVersion: manifest.version, + error, + }); + } throw error; } } - }); + }; + + if (typeof db.transaction === "function") { + await db.transaction(async (tx) => applyWithClient(tx as PluginDatabaseClient)); + } else { + await applyWithClient(db); + } return namespace; }, diff --git a/server/src/services/plugin-host-services.ts b/server/src/services/plugin-host-services.ts index fbb2dc05904..e97ab8d0a8c 100644 --- a/server/src/services/plugin-host-services.ts +++ b/server/src/services/plugin-host-services.ts @@ -22,6 +22,7 @@ import type { PluginIssueOrchestrationSummary, } from "@paperclipai/plugin-sdk"; import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared"; +import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { companyService } from "./companies.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; @@ -34,12 +35,27 @@ import { budgetService } from "./budgets.js"; import { issueApprovalService } from "./issue-approvals.js"; import { subscribeCompanyLiveEvents } from "./live-events.js"; import { randomUUID } from "node:crypto"; +import path from "node:path"; import { activityService } from "./activity.js"; import { costService } from "./costs.js"; import { assetService } from "./assets.js"; import { pluginRegistryService } from "./plugin-registry.js"; import { pluginStateStore } from "./plugin-state-store.js"; import { pluginDatabaseService } from "./plugin-database.js"; +import { pluginManagedAgentService } from "./plugin-managed-agents.js"; +import { pluginManagedRoutineService } from "./plugin-managed-routines.js"; +import { + assertConfiguredLocalFolder, + assertWritableConfiguredLocalFolder, + getStoredLocalFolders, + inspectPluginLocalFolder, + listPluginLocalFolderEntries, + preparePluginLocalFolder, + readPluginLocalFolderText, + requireLocalFolderDeclaration, + setStoredLocalFolder, + writePluginLocalFolderTextAtomic, +} from "./plugin-local-folders.js"; import { createPluginSecretsHandler } from "./plugin-secrets-handler.js"; import { logActivity } from "./activity-log.js"; import type { PluginEventBus } from "./plugin-event-bus.js"; @@ -460,7 +476,7 @@ export function buildHostServices( pluginKey: string, eventBus: PluginEventBus, notifyWorker?: (method: string, params: unknown) => void, - options: { pluginWorkerManager?: PluginWorkerManager } = {}, + options: { pluginWorkerManager?: PluginWorkerManager; manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 } = {}, ): HostServices & { dispose(): void } { const registry = pluginRegistryService(db); const stateStore = pluginStateStore(db); @@ -468,6 +484,31 @@ export function buildHostServices( const secretsHandler = createPluginSecretsHandler({ db, pluginId }); const companies = companyService(db); const agents = agentService(db); + const managedAgents = pluginManagedAgentService(db, { + pluginId, + pluginKey, + manifest: options.manifest, + instructionTemplateVariables: async (companyId) => { + const variables: Record = {}; + for (const declaration of options.manifest?.localFolders ?? []) { + const status = await inspectPluginLocalFolder({ + folderKey: declaration.folderKey, + declaration, + storedConfig: await getStoredLocalFolderConfig(companyId, declaration.folderKey), + }); + const prefix = `localFolders.${declaration.folderKey}`; + variables[`${prefix}.path`] = status.realPath ?? status.path ?? null; + variables[`${prefix}.agentsPath`] = status.realPath ? path.join(status.realPath, "AGENTS.md") : null; + } + return variables; + }, + }); + const managedRoutines = pluginManagedRoutineService(db, { + pluginId, + pluginKey, + manifest: options.manifest, + pluginWorkerManager: options.pluginWorkerManager, + }); const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, }); @@ -518,6 +559,23 @@ export function buildHostServices( */ const ensurePluginAvailableForCompany = async (_companyId: string) => {}; + const getLocalFolderDeclaration = (folderKey: string) => + requireLocalFolderDeclaration(options.manifest?.localFolders, folderKey); + + const getStoredLocalFolderConfig = async (companyId: string, folderKey: string) => { + ensureCompanyId(companyId); + await ensurePluginAvailableForCompany(companyId); + const settings = await registry.getCompanySettings(pluginId, companyId); + return getStoredLocalFolders(settings?.settingsJson)[folderKey] ?? null; + }; + + const inspectStoredLocalFolder = async (companyId: string, folderKey: string) => + inspectPluginLocalFolder({ + folderKey, + declaration: getLocalFolderDeclaration(folderKey), + storedConfig: await getStoredLocalFolderConfig(companyId, folderKey), + }); + const inCompany = ( record: T | null | undefined, companyId: string, @@ -752,6 +810,86 @@ export function buildHostServices( }, }, + localFolders: { + async declarations() { + return options.manifest?.localFolders ?? []; + }, + + async configure(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + const declaration = getLocalFolderDeclaration(params.folderKey); + const existing = await registry.getCompanySettings(pluginId, companyId); + const existingConfig = getStoredLocalFolders(existing?.settingsJson)[params.folderKey] ?? null; + await preparePluginLocalFolder({ + folderKey: params.folderKey, + declaration, + storedConfig: existingConfig, + overrideConfig: { + path: params.path, + }, + }); + const status = await inspectPluginLocalFolder({ + folderKey: params.folderKey, + declaration, + storedConfig: existingConfig, + overrideConfig: { + path: params.path, + }, + }); + + const nextSettings = setStoredLocalFolder(existing?.settingsJson, params.folderKey, { + path: params.path, + access: status.access, + requiredDirectories: status.requiredDirectories, + requiredFiles: status.requiredFiles, + }); + await registry.upsertCompanySettings(pluginId, companyId, { + enabled: existing?.enabled ?? true, + settingsJson: nextSettings, + lastError: status.healthy ? null : status.problems.map((item: { message: string }) => item.message).join("; "), + }); + return status; + }, + + async status(params) { + return inspectStoredLocalFolder(params.companyId, params.folderKey); + }, + + async list(params) { + const status = await inspectStoredLocalFolder(params.companyId, params.folderKey); + assertConfiguredLocalFolder(status); + const listing = await listPluginLocalFolderEntries(status.realPath!, { + relativePath: params.relativePath, + recursive: params.recursive, + maxEntries: params.maxEntries, + }); + return { ...listing, folderKey: params.folderKey }; + }, + + async readText(params) { + const status = await inspectStoredLocalFolder(params.companyId, params.folderKey); + assertConfiguredLocalFolder(status); + return readPluginLocalFolderText(status.realPath!, params.relativePath); + }, + + async writeTextAtomic(params) { + const companyId = ensureCompanyId(params.companyId); + await preparePluginLocalFolder({ + folderKey: params.folderKey, + declaration: getLocalFolderDeclaration(params.folderKey), + storedConfig: await getStoredLocalFolderConfig(companyId, params.folderKey), + }); + const status = await inspectStoredLocalFolder(companyId, params.folderKey); + assertWritableConfiguredLocalFolder(status); + if (status.access !== "readWrite" || !status.writable) { + throw new Error("Local folder is not configured for writes"); + } + await writePluginLocalFolderTextAtomic(status.realPath!, params.relativePath, params.contents); + return inspectStoredLocalFolder(companyId, params.folderKey); + }, + }, + state: { async get(params) { return stateStore.get(pluginId, params.scopeKind as any, params.stateKey, { @@ -1013,6 +1151,77 @@ export function buildHostServices( updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), }; }, + async getManaged(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return projects.resolveManagedProject({ + companyId, + pluginId, + pluginKey, + projectKey: params.projectKey, + createIfMissing: false, + }); + }, + async reconcileManaged(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return projects.resolveManagedProject({ + companyId, + pluginId, + pluginKey, + projectKey: params.projectKey, + }); + }, + async resetManaged(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return projects.resolveManagedProject({ + companyId, + pluginId, + pluginKey, + projectKey: params.projectKey, + reset: true, + }); + }, + }, + + routines: { + async managedGet(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedRoutines.get(params.routineKey, companyId); + }, + async managedReconcile(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedRoutines.reconcile(params.routineKey, companyId, { + assigneeAgentId: params.assigneeAgentId, + projectId: params.projectId, + }); + }, + async managedReset(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedRoutines.reset(params.routineKey, companyId, { + assigneeAgentId: params.assigneeAgentId, + projectId: params.projectId, + }); + }, + async managedUpdate(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedRoutines.update(params.routineKey, companyId, { + status: params.status, + }); + }, + async managedRun(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedRoutines.run(params.routineKey, companyId, { + assigneeAgentId: params.assigneeAgentId, + projectId: params.projectId, + }); + }, }, issues: { @@ -1031,8 +1240,12 @@ export function buildHostServices( async create(params) { const companyId = ensureCompanyId(params.companyId); await ensurePluginAvailableForCompany(companyId); - const { actorAgentId, actorUserId, actorRunId, originKind, ...issueInput } = params; - const normalizedOriginKind = normalizePluginOriginKind(originKind); + const { actorAgentId, actorUserId, actorRunId, originKind, surfaceVisibility, ...issueInput } = params; + const normalizedOriginKind = normalizePluginOriginKind( + surfaceVisibility === "plugin_operation" && !originKind + ? pluginOperationIssueOriginKind(pluginKey) + : originKind, + ); const issue = (await issues.create(companyId, { ...(issueInput as any), originKind: normalizedOriginKind, @@ -1641,6 +1854,21 @@ export function buildHostServices( if (!run) throw new Error("Agent wakeup was skipped by heartbeat policy"); return { runId: run.id }; }, + async managedGet(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedAgents.get(params.agentKey, companyId); + }, + async managedReconcile(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedAgents.reconcile(params.agentKey, companyId); + }, + async managedReset(params) { + const companyId = ensureCompanyId(params.companyId); + await ensurePluginAvailableForCompany(companyId); + return managedAgents.reset(params.agentKey, companyId); + }, }, goals: { diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 95801180153..073620b429e 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -29,7 +29,7 @@ import { readdir, readFile, rm, stat } from "node:fs/promises"; import { execFile } from "node:child_process"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; import type { Db } from "@paperclipai/db"; import type { @@ -248,6 +248,8 @@ export interface PluginRuntimeServices { instanceInfo: { instanceId: string; hostVersion: string; + deploymentMode?: "local_trusted" | "authenticated"; + deploymentExposure?: "private" | "public"; }; } @@ -932,7 +934,10 @@ export function pluginLoader( try { // Dynamic import works for both .js (ESM) and .cjs (CJS) manifests - const mod = await import(manifestPath) as Record; + const manifestUrl = pathToFileURL(manifestPath); + const manifestStat = await stat(manifestPath); + manifestUrl.searchParams.set("mtime", String(Math.trunc(manifestStat.mtimeMs))); + const mod = await import(manifestUrl.href) as Record; // The manifest may be the default export or the module itself raw = mod["default"] ?? mod; } catch (err) { @@ -944,6 +949,51 @@ export function pluginLoader( return manifestValidator.parseOrThrow(raw); } + async function loadManifestFromPackageRoot( + packageRoot: string, + ): Promise { + const pkgJson = await readPackageJson(packageRoot); + if (!pkgJson) return null; + + const manifestPath = resolveManifestPath(packageRoot, pkgJson); + if (!manifestPath || !existsSync(manifestPath)) return null; + + return loadManifestFromPath(manifestPath); + } + + async function refreshPluginManifestFromPackage( + plugin: PluginRecord, + packageRoot: string, + ): Promise { + const manifest = await loadManifestFromPackageRoot(packageRoot); + if (!manifest) { + throw new Error(`Plugin package ${plugin.packageName} no longer exposes a Paperclip manifest`); + } + if (manifest.id !== plugin.pluginKey) { + throw new Error( + `Plugin manifest ID '${manifest.id}' does not match installed plugin '${plugin.pluginKey}'`, + ); + } + + if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) { + return plugin; + } + + await registry.update(plugin.id, { + packageName: plugin.packageName, + version: manifest.version, + manifest, + }); + + return { + ...plugin, + version: manifest.version, + apiVersion: manifest.apiVersion, + categories: manifest.categories, + manifestJson: manifest, + }; + } + /** * Build a DiscoveredPlugin from a resolved package directory, or null * if the package is not a Paperclip plugin. @@ -1256,22 +1306,43 @@ export function pluginLoader( async installPlugin(installOptions: PluginInstallOptions): Promise { const discovered = await fetchAndValidate(installOptions); + const manifest = discovered.manifest!; - // Step 6: Persist install record in Postgres (include packagePath for local installs so the worker can be resolved) - await registry.install( - { - packageName: discovered.packageName, - packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined, - }, - discovered.manifest!, - ); + // Step 6: Persist install record and apply plugin-owned schema migrations + // in one database transaction. If migration validation fails, the plugin + // row, namespace record, migration ledger, and created schema all roll back. + const installDb = manifest.database ? migrationDb : db; + await installDb.transaction(async (tx) => { + const txDb = tx as unknown as Db; + const txRegistry = pluginRegistryService(txDb); + const installed = await txRegistry.install( + { + packageName: discovered.packageName, + packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined, + }, + manifest, + ); + + if (!installed) { + throw new Error(`Plugin install did not return a registry row: ${manifest.id}`); + } + + if (manifest.database) { + await pluginDatabaseService(txDb).applyMigrations( + installed.id, + manifest, + discovered.packagePath, + { persistFailure: false }, + ); + } + }); log.info( { - pluginId: discovered.manifest!.id, + pluginId: manifest.id, packageName: discovered.packageName, version: discovered.version, - capabilities: discovered.manifest!.capabilities, + capabilities: manifest.capabilities, }, "plugin-loader: plugin installed successfully", ); @@ -1663,9 +1734,10 @@ export function pluginLoader( * `error` in the database when activation fails. */ async function activatePlugin(plugin: PluginRecord): Promise { - const manifest = plugin.manifestJson; const pluginId = plugin.id; const pluginKey = plugin.pluginKey; + let activePlugin = plugin; + let manifest = activePlugin.manifestJson; const registered: PluginLoadResult["registered"] = { worker: false, @@ -1705,8 +1777,10 @@ export function pluginLoader( // ------------------------------------------------------------------ // 1. Resolve worker entrypoint // ------------------------------------------------------------------ - const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir); - const packageRoot = resolvePluginPackageRoot(plugin, localPluginDir); + const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir); + activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot); + manifest = activePlugin.manifestJson; + const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir); // ------------------------------------------------------------------ // 2. Apply restricted database migrations before worker startup @@ -1746,12 +1820,16 @@ export function pluginLoader( databaseNamespace, hostHandlers, autoRestart: true, + env: { + PAPERCLIP_DEPLOYMENT_MODE: instanceInfo.deploymentMode ?? "", + PAPERCLIP_DEPLOYMENT_EXPOSURE: instanceInfo.deploymentExposure ?? "", + }, }; // Repo-local plugin installs can resolve workspace TS sources at runtime // (for example @paperclipai/shared exports). Run those workers through // the tsx loader so first-party example plugins work in development. - if (plugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { + if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH]; } @@ -1842,13 +1920,13 @@ export function pluginLoader( { pluginId, pluginKey, - version: plugin.version, + version: activePlugin.version, registered, }, "plugin-loader: plugin activated successfully", ); - return { plugin, success: true, registered }; + return { plugin: activePlugin, success: true, registered }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -1872,7 +1950,7 @@ export function pluginLoader( } return { - plugin, + plugin: activePlugin, success: false, error: errorMessage, registered, diff --git a/server/src/services/plugin-local-folders.ts b/server/src/services/plugin-local-folders.ts new file mode 100644 index 00000000000..8aa590d7bbe --- /dev/null +++ b/server/src/services/plugin-local-folders.ts @@ -0,0 +1,564 @@ +import { constants as fsConstants, promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { + PluginLocalFolderDeclaration, + PluginLocalFolderEntry, + PluginLocalFolderListing, + PluginLocalFolderProblem, + PluginLocalFolderStatus, +} from "@paperclipai/plugin-sdk"; +import { badRequest, forbidden, notFound } from "../errors.js"; + +export interface StoredPluginLocalFolderConfig { + path: string; + access?: "read" | "readWrite"; + requiredDirectories?: string[]; + requiredFiles?: string[]; + updatedAt?: string; +} + +export interface PluginLocalFolderSettingsJson { + localFolders?: Record; + [key: string]: unknown; +} + +const LOCAL_FOLDER_KEY_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/; + +function problem( + code: PluginLocalFolderProblem["code"], + message: string, + problemPath?: string, +): PluginLocalFolderProblem { + return { code, message, path: problemPath }; +} + +export function assertPluginLocalFolderKey(folderKey: string) { + if (!LOCAL_FOLDER_KEY_PATTERN.test(folderKey)) { + throw badRequest("folderKey must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, colons, underscores, or hyphens"); + } +} + +export function findLocalFolderDeclaration( + declarations: PluginLocalFolderDeclaration[] | undefined, + folderKey: string, +) { + return declarations?.find((declaration) => declaration.folderKey === folderKey) ?? null; +} + +export function requireLocalFolderDeclaration( + declarations: PluginLocalFolderDeclaration[] | undefined, + folderKey: string, +) { + assertPluginLocalFolderKey(folderKey); + const declaration = findLocalFolderDeclaration(declarations, folderKey); + if (!declaration) { + throw badRequest("Local folder key is not declared by this plugin manifest"); + } + return declaration; +} + +function normalizeRelativePath(relativePath: string): string { + if ( + !relativePath || + path.isAbsolute(relativePath) || + relativePath.includes("\\") || + relativePath.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw forbidden("Local folder relative paths must stay inside the configured root"); + } + return relativePath; +} + +function validateRequiredPath(pathValue: string, label: string): string { + try { + return normalizeRelativePath(pathValue); + } catch { + throw badRequest(`${label} must contain only relative paths without traversal, empty segments, or backslashes`); + } +} + +function normalizeListRelativePath(relativePath: string | null | undefined): string | null { + const trimmed = relativePath?.trim(); + if (!trimmed) return null; + return normalizeRelativePath(trimmed); +} + +function normalizeMaxEntries(value: number | undefined): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 1000; + return Math.max(1, Math.min(5000, Math.floor(value))); +} + +function mergeFolderConfig( + declaration: PluginLocalFolderDeclaration | null, + stored: StoredPluginLocalFolderConfig | null, + override?: Partial, +): StoredPluginLocalFolderConfig | null { + const pathValue = override?.path ?? stored?.path; + if (!pathValue) return null; + return { + path: pathValue, + access: declaration?.access ?? override?.access ?? stored?.access ?? "readWrite", + requiredDirectories: + declaration?.requiredDirectories ?? override?.requiredDirectories ?? stored?.requiredDirectories ?? [], + requiredFiles: + declaration?.requiredFiles ?? override?.requiredFiles ?? stored?.requiredFiles ?? [], + updatedAt: stored?.updatedAt, + }; +} + +export function getStoredLocalFolders(settingsJson: Record | null | undefined) { + const folders = (settingsJson as PluginLocalFolderSettingsJson | undefined)?.localFolders; + if (!folders || typeof folders !== "object") return {}; + return folders; +} + +export function setStoredLocalFolder( + settingsJson: Record | null | undefined, + folderKey: string, + config: StoredPluginLocalFolderConfig, +): PluginLocalFolderSettingsJson { + return { + ...(settingsJson ?? {}), + localFolders: { + ...getStoredLocalFolders(settingsJson), + [folderKey]: { + ...config, + updatedAt: new Date().toISOString(), + }, + }, + }; +} + +export async function inspectPluginLocalFolder(input: { + folderKey: string; + declaration?: PluginLocalFolderDeclaration | null; + storedConfig?: StoredPluginLocalFolderConfig | null; + overrideConfig?: Partial; +}): Promise { + assertPluginLocalFolderKey(input.folderKey); + const config = mergeFolderConfig( + input.declaration ?? null, + input.storedConfig ?? null, + input.overrideConfig, + ); + const access = config?.access ?? input.declaration?.access ?? "readWrite"; + const requiredDirectories = (config?.requiredDirectories ?? []).map((item) => + validateRequiredPath(item, "requiredDirectories"), + ); + const requiredFiles = (config?.requiredFiles ?? []).map((item) => + validateRequiredPath(item, "requiredFiles"), + ); + const checkedAt = new Date().toISOString(); + + if (!config?.path) { + return { + folderKey: input.folderKey, + configured: false, + path: null, + realPath: null, + access, + readable: false, + writable: false, + requiredDirectories, + requiredFiles, + missingDirectories: requiredDirectories, + missingFiles: requiredFiles, + healthy: false, + problems: [problem("not_configured", "No local folder path is configured.")], + checkedAt, + }; + } + + const configuredPath = path.resolve(config.path); + const problems: PluginLocalFolderProblem[] = []; + const missingDirectories: string[] = []; + const missingFiles: string[] = []; + const markRequiredPathsMissing = () => { + missingDirectories.push(...requiredDirectories); + missingFiles.push(...requiredFiles); + }; + let realPath: string | null = null; + let readable = false; + let writable = false; + + if (!path.isAbsolute(config.path)) { + problems.push(problem("not_absolute", "Local folder path must be absolute.", config.path)); + } + + try { + const stat = await fs.stat(configuredPath); + if (!stat.isDirectory()) { + problems.push(problem("not_directory", "Configured local folder path is not a directory.", configuredPath)); + markRequiredPathsMissing(); + } else { + realPath = await fs.realpath(configuredPath); + try { + await fs.access(realPath, fsConstants.R_OK); + readable = true; + } catch { + problems.push(problem("not_readable", "Configured local folder is not readable.", configuredPath)); + } + + if (access === "readWrite") { + try { + await fs.access(realPath, fsConstants.W_OK); + const probePath = path.join(realPath, `.paperclip-local-folder-probe-${process.pid}-${Date.now()}`); + await fs.writeFile(probePath, ""); + await fs.rm(probePath, { force: true }); + writable = true; + } catch { + problems.push(problem("not_writable", "Configured local folder is not writable.", configuredPath)); + } + } + + for (const requiredDir of requiredDirectories) { + const requiredStatus = await inspectChildPath(realPath, requiredDir, "directory"); + if (!requiredStatus.exists) { + missingDirectories.push(requiredDir); + problems.push(problem("missing_directory", "Required directory is missing.", requiredDir)); + } else if (!requiredStatus.contained) { + problems.push(problem("symlink_escape", "Required directory escapes the configured root.", requiredDir)); + } else if (!requiredStatus.matchesKind) { + missingDirectories.push(requiredDir); + problems.push(problem("missing_directory", "Required path is not a directory.", requiredDir)); + } + } + + for (const requiredFile of requiredFiles) { + const requiredStatus = await inspectChildPath(realPath, requiredFile, "file"); + if (!requiredStatus.exists) { + missingFiles.push(requiredFile); + problems.push(problem("missing_file", "Required file is missing.", requiredFile)); + } else if (!requiredStatus.contained) { + problems.push(problem("symlink_escape", "Required file escapes the configured root.", requiredFile)); + } else if (!requiredStatus.matchesKind) { + missingFiles.push(requiredFile); + problems.push(problem("missing_file", "Required path is not a file.", requiredFile)); + } + } + } + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; + problems.push(problem(code === "ENOENT" ? "missing" : "not_readable", "Configured local folder cannot be inspected.", configuredPath)); + if (code === "ENOENT") { + markRequiredPathsMissing(); + } + } + + return { + folderKey: input.folderKey, + configured: true, + path: configuredPath, + realPath, + access, + readable, + writable: access === "read" ? false : writable, + requiredDirectories, + requiredFiles, + missingDirectories, + missingFiles, + healthy: + problems.length === 0 && + readable && + (access === "read" || writable), + problems, + checkedAt, + }; +} + +function isInsideRoot(rootRealPath: string, candidateRealPath: string) { + const relative = path.relative(rootRealPath, candidateRealPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +async function assertPathInsideRoot(rootRealPath: string, candidatePath: string) { + const candidateRealPath = await fs.realpath(candidatePath); + if (!isInsideRoot(rootRealPath, candidateRealPath)) { + throw forbidden("Local folder symlink escape is not allowed"); + } + return candidateRealPath; +} + +async function ensureDirectoryInsideRoot(rootRealPath: string, relativePath: string) { + const normalized = normalizeRelativePath(relativePath); + const segments = normalized.split("/"); + let currentRealPath = rootRealPath; + + for (const segment of segments) { + const nextPath = path.join(currentRealPath, segment); + try { + const stat = await fs.stat(nextPath); + if (!stat.isDirectory()) { + throw badRequest("Required directory path exists but is not a directory"); + } + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; + if (code !== "ENOENT") throw error; + await fs.mkdir(nextPath); + } + + const nextRealPath = await fs.realpath(nextPath); + if (!isInsideRoot(rootRealPath, nextRealPath)) { + throw forbidden("Local folder symlink escape is not allowed"); + } + currentRealPath = nextRealPath; + } +} + +export async function preparePluginLocalFolder(input: { + folderKey: string; + declaration?: PluginLocalFolderDeclaration | null; + storedConfig?: StoredPluginLocalFolderConfig | null; + overrideConfig?: Partial; +}) { + assertPluginLocalFolderKey(input.folderKey); + const config = mergeFolderConfig( + input.declaration ?? null, + input.storedConfig ?? null, + input.overrideConfig, + ); + const access = config?.access ?? input.declaration?.access ?? "readWrite"; + if (!config?.path || access !== "readWrite" || !path.isAbsolute(config.path)) return; + + const configuredPath = path.resolve(config.path); + try { + const stat = await fs.stat(configuredPath); + if (!stat.isDirectory()) return; + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; + if (code !== "ENOENT") return; + try { + await fs.mkdir(configuredPath, { recursive: true }); + } catch { + return; + } + } + const rootRealPath = await fs.realpath(configuredPath); + + for (const requiredDir of config.requiredDirectories ?? []) { + await ensureDirectoryInsideRoot(rootRealPath, validateRequiredPath(requiredDir, "requiredDirectories")); + } +} + +async function inspectChildPath( + rootRealPath: string, + relativePath: string, + kind: "directory" | "file", +) { + let resolvedPath: Awaited>; + try { + resolvedPath = await resolvePluginLocalFolderPath(rootRealPath, relativePath, { + mustExist: true, + allowMissingLeaf: true, + }); + } catch { + return { exists: true, contained: false, matchesKind: false }; + } + if (!resolvedPath.exists) { + return { exists: false, contained: true, matchesKind: false }; + } + const stat = await fs.stat(resolvedPath.realPath); + return { + exists: true, + contained: true, + matchesKind: kind === "directory" ? stat.isDirectory() : stat.isFile(), + }; +} + +export async function resolvePluginLocalFolderPath( + rootPath: string, + relativePath: string, + options?: { mustExist?: boolean; allowMissingLeaf?: boolean }, +) { + const normalized = normalizeRelativePath(relativePath); + const rootRealPath = await fs.realpath(rootPath); + const absolutePath = path.resolve(rootRealPath, normalized); + const relativeFromRoot = path.relative(rootRealPath, absolutePath); + if (relativeFromRoot.startsWith("..") || path.isAbsolute(relativeFromRoot)) { + throw forbidden("Local folder path traversal is not allowed"); + } + + try { + const realPath = await fs.realpath(absolutePath); + const realRelative = path.relative(rootRealPath, realPath); + if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) { + throw forbidden("Local folder symlink escape is not allowed"); + } + return { absolutePath, realPath, exists: true }; + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : ""; + if (code !== "ENOENT" || options?.mustExist) { + if (options?.allowMissingLeaf && code === "ENOENT") { + return { absolutePath, realPath: absolutePath, exists: false }; + } + throw error; + } + + const parentRealPath = await fs.realpath(path.dirname(absolutePath)); + const parentRelative = path.relative(rootRealPath, parentRealPath); + if (parentRelative.startsWith("..") || path.isAbsolute(parentRelative)) { + throw forbidden("Local folder symlink escape is not allowed"); + } + return { absolutePath, realPath: absolutePath, exists: false }; + } +} + +export async function readPluginLocalFolderText(rootPath: string, relativePath: string) { + const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath, { mustExist: true }); + const stat = await fs.stat(resolved.realPath); + if (!stat.isFile()) { + throw badRequest("Local folder read target must be a file"); + } + return fs.readFile(resolved.realPath, "utf8"); +} + +export async function listPluginLocalFolderEntries( + rootPath: string, + options: { relativePath?: string | null; recursive?: boolean; maxEntries?: number } = {}, +): Promise { + const rootRealPath = await fs.realpath(rootPath); + const relativePath = normalizeListRelativePath(options.relativePath); + const target = relativePath + ? await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true }) + : { absolutePath: rootRealPath, realPath: rootRealPath, exists: true }; + const targetStat = await fs.stat(target.realPath); + if (!targetStat.isDirectory()) { + throw badRequest("Local folder list target must be a directory"); + } + + const maxEntries = normalizeMaxEntries(options.maxEntries); + const entries: PluginLocalFolderEntry[] = []; + let truncated = false; + + const visit = async (directoryRealPath: string, directoryRelativePath: string | null) => { + if (truncated) return; + const dirents = await fs.readdir(directoryRealPath, { withFileTypes: true }); + dirents.sort((a, b) => a.name.localeCompare(b.name)); + + for (const dirent of dirents) { + if (entries.length >= maxEntries) { + truncated = true; + return; + } + + const childRelativePath = directoryRelativePath ? `${directoryRelativePath}/${dirent.name}` : dirent.name; + let resolvedChild: Awaited>; + try { + resolvedChild = await resolvePluginLocalFolderPath(rootRealPath, childRelativePath, { mustExist: true }); + } catch { + continue; + } + + const stat = await fs.stat(resolvedChild.realPath).catch(() => null); + if (!stat) continue; + const kind = stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null; + if (!kind) continue; + + entries.push({ + path: childRelativePath, + name: dirent.name, + kind, + size: kind === "file" ? stat.size : null, + modifiedAt: stat.mtime.toISOString(), + }); + + if (options.recursive && kind === "directory") { + await visit(resolvedChild.realPath, childRelativePath); + if (truncated) return; + } + } + }; + + await visit(target.realPath, relativePath); + return { + folderKey: "list-result", + relativePath, + entries, + truncated, + }; +} + +export async function writePluginLocalFolderTextAtomic( + rootPath: string, + relativePath: string, + contents: string, +) { + const rootRealPath = await fs.realpath(rootPath); + const resolved = await resolvePluginLocalFolderPath(rootPath, relativePath); + await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true }); + await assertPathInsideRoot(rootRealPath, path.dirname(resolved.absolutePath)); + const tempPath = path.join( + path.dirname(resolved.absolutePath), + `.paperclip-${path.basename(resolved.absolutePath)}-${process.pid}-${randomUUID()}.tmp`, + ); + let tempCreated = false; + try { + const handle = await fs.open(tempPath, "wx"); + tempCreated = true; + try { + await assertPathInsideRoot(rootRealPath, tempPath); + await handle.writeFile(contents, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + } catch (error) { + if (tempCreated) { + await fs.rm(tempPath, { force: true }); + } + throw error; + } + + try { + await resolvePluginLocalFolderPath(rootRealPath, relativePath); + await fs.rename(tempPath, resolved.absolutePath); + await resolvePluginLocalFolderPath(rootRealPath, relativePath, { mustExist: true }); + } catch (error) { + await fs.rm(tempPath, { force: true }); + throw error; + } + + if (process.platform !== "win32") { + const dirHandle = await fs.open(path.dirname(resolved.absolutePath), "r"); + try { + await dirHandle.sync(); + } finally { + await dirHandle.close(); + } + } + + return inspectPluginLocalFolder({ + folderKey: "write-result", + storedConfig: { + path: rootPath, + access: "readWrite", + }, + }); +} + +export function defaultLocalFolderBasePath(pluginKey: string, companyId: string) { + return path.join(os.homedir(), ".paperclip", "plugin-data", companyId, pluginKey); +} + +export function assertConfiguredLocalFolder(status: PluginLocalFolderStatus) { + if (!status.configured || !status.realPath || !status.readable) { + throw notFound("Local folder is not configured or readable"); + } + if (!status.healthy) { + throw badRequest("Local folder is not healthy"); + } +} + +export function assertWritableConfiguredLocalFolder(status: PluginLocalFolderStatus) { + if (!status.configured || !status.realPath || !status.readable) { + throw notFound("Local folder is not configured or readable"); + } + const onlyMissingRequiredPaths = status.problems.every((item) => + item.code === "missing_directory" || item.code === "missing_file" + ); + if (!status.healthy && !onlyMissingRequiredPaths) { + throw badRequest("Local folder is not healthy"); + } +} diff --git a/server/src/services/plugin-managed-agents.ts b/server/src/services/plugin-managed-agents.ts new file mode 100644 index 00000000000..84a55c3a318 --- /dev/null +++ b/server/src/services/plugin-managed-agents.ts @@ -0,0 +1,508 @@ +import { and, eq, ne } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + companies, + pluginEntities, + pluginManagedResources, +} from "@paperclipai/db"; +import type { + Agent, + PaperclipPluginManifestV1, + PluginManagedAgentDeclaration, + PluginManagedAgentResolution, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { agentService } from "./agents.js"; +import { approvalService } from "./approvals.js"; +import { logActivity } from "./activity-log.js"; +import { agentInstructionsService } from "./agent-instructions.js"; + +const MANAGED_AGENT_ENTITY_TYPE = "managed_agent"; +const DEFAULT_MANAGED_AGENT_ADAPTER_TYPE = "process"; + +interface PluginManagedAgentServiceOptions { + pluginId: string; + pluginKey: string; + manifest?: PaperclipPluginManifestV1 | null; + instructionTemplateVariables?: (companyId: string) => Promise>; +} + +function bindingExternalId(companyId: string, agentKey: string) { + return `managed:agent:${companyId}:${agentKey}`; +} + +function managedMetadata( + pluginId: string, + pluginKey: string, + declaration: PluginManagedAgentDeclaration, + existing?: Record | null, +) { + return { + ...(existing ?? {}), + paperclipManagedResource: { + pluginId, + pluginKey, + resourceKind: "agent", + resourceKey: declaration.agentKey, + }, + pluginManagedAgent: { + pluginId, + pluginKey, + agentKey: declaration.agentKey, + displayName: declaration.displayName, + instructions: declaration.instructions ?? null, + }, + }; +} + +function normalizeAdapterType(value: unknown) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function fallbackAdapterType(declaration: PluginManagedAgentDeclaration) { + return normalizeAdapterType(declaration.adapterType) ?? DEFAULT_MANAGED_AGENT_ADAPTER_TYPE; +} + +function adapterPreference(declaration: PluginManagedAgentDeclaration) { + const seen = new Set(); + const preference: string[] = []; + for (const value of declaration.adapterPreference ?? []) { + const adapterType = normalizeAdapterType(value); + if (!adapterType || seen.has(adapterType)) continue; + seen.add(adapterType); + preference.push(adapterType); + } + return preference; +} + +function selectPreferredAdapterType( + declaration: PluginManagedAgentDeclaration, + usage: Array<{ adapterType: string; count: number }>, +) { + const fallback = fallbackAdapterType(declaration); + const preference = adapterPreference(declaration); + if (preference.length === 0) return fallback; + + const rank = new Map(preference.map((adapterType, index) => [adapterType, index])); + let selected: { adapterType: string; count: number; rank: number } | null = null; + for (const entry of usage) { + const adapterRank = rank.get(entry.adapterType); + if (adapterRank === undefined) continue; + if ( + !selected || + entry.count > selected.count || + (entry.count === selected.count && adapterRank < selected.rank) + ) { + selected = { ...entry, rank: adapterRank }; + } + } + return selected?.adapterType ?? fallback; +} + +function declarationPatch(declaration: PluginManagedAgentDeclaration, input: { adapterType?: string } = {}) { + return { + name: declaration.displayName, + role: declaration.role ?? "general", + title: declaration.title ?? null, + icon: declaration.icon ?? null, + capabilities: declaration.capabilities ?? null, + adapterType: input.adapterType ?? fallbackAdapterType(declaration), + adapterConfig: declaration.adapterConfig ?? {}, + runtimeConfig: declaration.runtimeConfig ?? {}, + permissions: declaration.permissions ?? {}, + budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, + }; +} + +function applyInstructionTemplateVariables( + content: string, + variables: Record, +) { + let next = content; + for (const [key, value] of Object.entries(variables)) { + next = next.replaceAll(`{{${key}}}`, value?.trim() || "(not configured)"); + } + return next; +} + +function rowIsManagedAgent( + row: typeof agents.$inferSelect, + pluginKey: string, + agentKey: string, +) { + const metadata = row.metadata; + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return false; + const marker = (metadata as Record).paperclipManagedResource; + if (!marker || typeof marker !== "object" || Array.isArray(marker)) return false; + const record = marker as Record; + return ( + record.pluginKey === pluginKey + && record.resourceKind === "agent" + && record.resourceKey === agentKey + ); +} + +export function pluginManagedAgentService( + db: Db, + options: PluginManagedAgentServiceOptions, +) { + const agentSvc = agentService(db); + const approvalSvc = approvalService(db); + const instructions = agentInstructionsService(); + + function declarationFor(agentKey: string) { + const declaration = options.manifest?.agents?.find((agent) => agent.agentKey === agentKey); + if (!declaration) { + throw notFound(`Managed agent declaration not found: ${agentKey}`); + } + return declaration; + } + + async function getBinding(companyId: string, agentKey: string) { + return db + .select() + .from(pluginEntities) + .where( + and( + eq(pluginEntities.pluginId, options.pluginId), + eq(pluginEntities.entityType, MANAGED_AGENT_ENTITY_TYPE), + eq(pluginEntities.externalId, bindingExternalId(companyId, agentKey)), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function upsertBinding( + companyId: string, + declaration: PluginManagedAgentDeclaration, + agentId: string, + extraData: Record = {}, + effectiveAdapterType?: string, + ) { + const adapterType = effectiveAdapterType ?? (await resolveManagedAdapterType(companyId, declaration)); + const defaultsJson = { + agentKey: declaration.agentKey, + displayName: declaration.displayName, + role: declaration.role ?? "general", + title: declaration.title ?? null, + icon: declaration.icon ?? null, + capabilities: declaration.capabilities ?? null, + adapterType, + adapterPreference: declaration.adapterPreference ?? null, + adapterConfig: declaration.adapterConfig ?? {}, + runtimeConfig: declaration.runtimeConfig ?? {}, + permissions: declaration.permissions ?? {}, + budgetMonthlyCents: declaration.budgetMonthlyCents ?? 0, + instructions: declaration.instructions ?? null, + }; + const managedResource = await db + .select({ id: pluginManagedResources.id }) + .from(pluginManagedResources) + .where(and( + eq(pluginManagedResources.companyId, companyId), + eq(pluginManagedResources.pluginId, options.pluginId), + eq(pluginManagedResources.resourceKind, "agent"), + eq(pluginManagedResources.resourceKey, declaration.agentKey), + )) + .then((rows) => rows[0] ?? null); + if (managedResource) { + await db + .update(pluginManagedResources) + .set({ resourceId: agentId, defaultsJson, updatedAt: new Date() }) + .where(eq(pluginManagedResources.id, managedResource.id)); + } else { + await db.insert(pluginManagedResources).values({ + companyId, + pluginId: options.pluginId, + pluginKey: options.pluginKey, + resourceKind: "agent", + resourceKey: declaration.agentKey, + resourceId: agentId, + defaultsJson, + }); + } + + const externalId = bindingExternalId(companyId, declaration.agentKey); + const data = { + pluginKey: options.pluginKey, + resourceKind: "agent", + resourceKey: declaration.agentKey, + agentId, + adapterType, + declarationSnapshot: declaration, + lastReconciledAt: new Date().toISOString(), + ...extraData, + }; + const existing = await getBinding(companyId, declaration.agentKey); + if (existing) { + return db + .update(pluginEntities) + .set({ + scopeKind: "company", + scopeId: companyId, + title: declaration.displayName, + status: "resolved", + data, + updatedAt: new Date(), + }) + .where(eq(pluginEntities.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + return db + .insert(pluginEntities) + .values({ + pluginId: options.pluginId, + entityType: MANAGED_AGENT_ENTITY_TYPE, + scopeKind: "company", + scopeId: companyId, + externalId, + title: declaration.displayName, + status: "resolved", + data, + }) + .returning() + .then((rows) => rows[0]); + } + + async function findRelinkCandidate(companyId: string, declaration: PluginManagedAgentDeclaration) { + const rows = await db + .select() + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + return rows.find((row) => rowIsManagedAgent(row, options.pluginKey, declaration.agentKey)) ?? null; + } + + async function companyAdapterUsage(companyId: string) { + const rows = await db + .select({ adapterType: agents.adapterType }) + .from(agents) + .where(and(eq(agents.companyId, companyId), ne(agents.status, "terminated"))); + const counts = new Map(); + for (const row of rows) { + const adapterType = normalizeAdapterType(row.adapterType); + if (!adapterType) continue; + counts.set(adapterType, (counts.get(adapterType) ?? 0) + 1); + } + return [...counts.entries()] + .map(([adapterType, count]) => ({ adapterType, count })) + .sort((a, b) => b.count - a.count || a.adapterType.localeCompare(b.adapterType)) + .slice(0, 10); + } + + async function resolveManagedAdapterType(companyId: string, declaration: PluginManagedAgentDeclaration) { + return selectPreferredAdapterType(declaration, await companyAdapterUsage(companyId)); + } + + async function materializeDeclaredInstructions( + companyId: string, + agent: Agent, + declaration: PluginManagedAgentDeclaration, + options: { replaceExisting: boolean }, + ): Promise { + const instructionDeclaration = declaration.instructions; + if (!instructionDeclaration?.content) return agent; + + const entryFile = instructionDeclaration.entryFile ?? "AGENTS.md"; + const variables = await optionsForInstructionVariables(companyId); + const materialized = await instructions.materializeManagedBundle( + agent, + { [entryFile]: applyInstructionTemplateVariables(instructionDeclaration.content, variables) }, + { + entryFile, + replaceExisting: options.replaceExisting, + clearLegacyPromptTemplate: true, + }, + ); + const updated = await agentSvc.update(agent.id, { + adapterConfig: materialized.adapterConfig, + }, { + recordRevision: { + source: `plugin:${optionsForRevisionSource()}:managed-agent-instructions`, + }, + }); + return (updated as Agent | null) ?? { ...agent, adapterConfig: materialized.adapterConfig }; + } + + async function optionsForInstructionVariables(companyId: string) { + return options.instructionTemplateVariables ? options.instructionTemplateVariables(companyId) : {}; + } + + function optionsForRevisionSource() { + return options.pluginKey; + } + + function resolution( + companyId: string, + declaration: PluginManagedAgentDeclaration, + agent: Agent | null, + status: PluginManagedAgentResolution["status"], + approvalId?: string | null, + ): PluginManagedAgentResolution { + return { + pluginKey: options.pluginKey, + resourceKind: "agent", + resourceKey: declaration.agentKey, + companyId, + agentId: agent?.id ?? null, + agent, + status, + approvalId: approvalId ?? null, + }; + } + + async function createManagedAgent(companyId: string, declaration: PluginManagedAgentDeclaration) { + const company = await db + .select() + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + if (!company) throw notFound("Company not found"); + + const requiresApproval = company.requireBoardApprovalForNewAgents; + const adapterType = await resolveManagedAdapterType(companyId, declaration); + let created = await agentSvc.create(companyId, { + ...declarationPatch(declaration, { adapterType }), + status: requiresApproval ? "pending_approval" : declaration.status ?? "idle", + metadata: managedMetadata(options.pluginId, options.pluginKey, declaration), + spentMonthlyCents: 0, + lastHeartbeatAt: null, + }) as Agent; + created = await materializeDeclaredInstructions(companyId, created, declaration, { replaceExisting: true }); + + let approvalId: string | null = null; + if (requiresApproval) { + const approval = await approvalSvc.create(companyId, { + type: "hire_agent", + requestedByAgentId: null, + requestedByUserId: null, + status: "pending", + payload: { + name: created.name, + role: created.role, + title: created.title, + icon: created.icon, + reportsTo: created.reportsTo, + capabilities: created.capabilities, + adapterType: created.adapterType, + adapterConfig: created.adapterConfig, + runtimeConfig: created.runtimeConfig, + budgetMonthlyCents: created.budgetMonthlyCents, + metadata: created.metadata, + agentId: created.id, + sourcePluginId: options.pluginId, + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.agentKey, + }, + decisionNote: null, + decidedByUserId: null, + decidedAt: null, + updatedAt: new Date(), + }); + approvalId = approval.id; + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "approval.created", + entityType: "approval", + entityId: approval.id, + details: { + type: "hire_agent", + linkedAgentId: created.id, + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.agentKey, + }, + }); + } + + await upsertBinding(companyId, declaration, created.id, { approvalId }, adapterType); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_agent.created", + entityType: "agent", + entityId: created.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.agentKey, + adapterType, + requiresApproval, + approvalId, + }, + }); + return resolution(companyId, declaration, created as Agent, "created", approvalId); + } + + async function get(agentKey: string, companyId: string) { + const declaration = declarationFor(agentKey); + const binding = await getBinding(companyId, agentKey); + const boundAgentId = typeof binding?.data?.agentId === "string" ? binding.data.agentId : null; + if (!boundAgentId) return resolution(companyId, declaration, null, "missing"); + const agent = await agentSvc.getById(boundAgentId); + if (!agent || agent.companyId !== companyId || agent.status === "terminated") { + return resolution(companyId, declaration, null, "missing"); + } + return resolution(companyId, declaration, agent as Agent, "resolved"); + } + + async function reconcile(agentKey: string, companyId: string) { + const declaration = declarationFor(agentKey); + const current = await get(agentKey, companyId); + if (current.agent) { + await upsertBinding(companyId, declaration, current.agent.id); + return current; + } + + const relinkCandidate = await findRelinkCandidate(companyId, declaration); + if (relinkCandidate) { + await upsertBinding(companyId, declaration, relinkCandidate.id); + const agent = await agentSvc.getById(relinkCandidate.id); + return resolution(companyId, declaration, agent as Agent, "relinked"); + } + + return createManagedAgent(companyId, declaration); + } + + async function reset(agentKey: string, companyId: string) { + const declaration = declarationFor(agentKey); + const reconciled = await reconcile(agentKey, companyId); + if (!reconciled.agent) return reconciled; + const currentMetadata = reconciled.agent.metadata && typeof reconciled.agent.metadata === "object" + ? reconciled.agent.metadata + : {}; + const adapterType = await resolveManagedAdapterType(companyId, declaration); + const updated = await agentSvc.update(reconciled.agent.id, { + ...declarationPatch(declaration, { adapterType }), + metadata: managedMetadata(options.pluginId, options.pluginKey, declaration, currentMetadata), + }, { + recordRevision: { + source: `plugin:${options.pluginKey}:managed-agent-reset`, + }, + }); + if (!updated) throw notFound("Managed agent not found"); + const updatedAgent = await materializeDeclaredInstructions(companyId, updated as Agent, declaration, { replaceExisting: true }); + await upsertBinding(companyId, declaration, updatedAgent.id, {}, adapterType); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_agent.reset", + entityType: "agent", + entityId: updatedAgent.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.agentKey, + }, + }); + return resolution(companyId, declaration, updatedAgent, "reset"); + } + + return { + get, + reconcile, + reset, + }; +} diff --git a/server/src/services/plugin-managed-routines.ts b/server/src/services/plugin-managed-routines.ts new file mode 100644 index 00000000000..94027dd3236 --- /dev/null +++ b/server/src/services/plugin-managed-routines.ts @@ -0,0 +1,523 @@ +import { and, eq } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { + agents, + pluginManagedResources, + plugins, + projects, + routines, + routineTriggers, +} from "@paperclipai/db"; +import type { + CreateRoutineTrigger, + PluginManagedResourceRef, + PluginManagedRoutineDeclaration, + PluginManagedRoutineResolution, + Routine, + RoutineManagedByPlugin, + RoutineStatus, +} from "@paperclipai/shared"; +import { ROUTINE_STATUSES } from "@paperclipai/shared"; +import { notFound, unprocessable } from "../errors.js"; +import { logActivity } from "./activity-log.js"; +import { routineService } from "./routines.js"; +import type { PluginWorkerManager } from "./plugin-worker-manager.js"; + +const MANAGED_ROUTINE_RESOURCE_KIND = "routine"; + +interface PluginManagedRoutineServiceOptions { + pluginId: string; + pluginKey: string; + manifest?: import("@paperclipai/shared").PaperclipPluginManifestV1 | null; + pluginWorkerManager?: PluginWorkerManager; +} + +interface RoutineOverrides { + assigneeAgentId?: string | null; + projectId?: string | null; +} + +function buildRoutineDefaults(declaration: PluginManagedRoutineDeclaration) { + return { + routineKey: declaration.routineKey, + title: declaration.title, + description: declaration.description ?? null, + assigneeRef: declaration.assigneeRef ?? null, + projectRef: declaration.projectRef ?? null, + goalId: declaration.goalId ?? null, + status: declaration.status ?? null, + priority: declaration.priority ?? "medium", + concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", + catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + variables: declaration.variables ?? [], + triggers: declaration.triggers ?? [], + issueTemplate: declaration.issueTemplate ?? null, + }; +} + +function normalizeRef( + pluginKey: string, + ref: PluginManagedResourceRef | null | undefined, + resourceKind: "agent" | "project", +) { + if (!ref) return null; + if (ref.resourceKind !== resourceKind) { + throw unprocessable(`Managed routine ${resourceKind} ref must target ${resourceKind}`); + } + if (ref.pluginKey && ref.pluginKey !== pluginKey) { + throw unprocessable("Managed routine refs must target the declaring plugin"); + } + return { ...ref, pluginKey }; +} + +function managedByPlugin(row: { + id: string; + pluginId: string; + pluginKey: string; + manifestJson: { displayName?: string } | null; + resourceKey: string; + defaultsJson: Record; + createdAt: Date; + updatedAt: Date; +}): RoutineManagedByPlugin { + return { + id: row.id, + pluginId: row.pluginId, + pluginKey: row.pluginKey, + pluginDisplayName: row.manifestJson?.displayName ?? row.pluginKey, + resourceKind: "routine", + resourceKey: row.resourceKey, + defaultsJson: row.defaultsJson, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function triggerInput(trigger: NonNullable[number]): CreateRoutineTrigger { + if (trigger.kind === "schedule") { + if (!trigger.cronExpression) { + throw unprocessable("Managed schedule routine triggers require cronExpression"); + } + return { + kind: "schedule", + label: trigger.label ?? null, + enabled: trigger.enabled ?? true, + cronExpression: trigger.cronExpression, + timezone: trigger.timezone ?? "UTC", + }; + } + if (trigger.kind === "webhook") { + return { + kind: "webhook", + label: trigger.label ?? null, + enabled: trigger.enabled ?? true, + signingMode: (trigger.signingMode ?? "bearer") as Extract["signingMode"], + replayWindowSec: trigger.replayWindowSec ?? 300, + }; + } + return { + kind: "api", + label: trigger.label ?? null, + enabled: trigger.enabled ?? true, + }; +} + +export function pluginManagedRoutineService( + db: Db, + options: PluginManagedRoutineServiceOptions, +) { + const routinesSvc = routineService(db, { + pluginWorkerManager: options.pluginWorkerManager, + }); + + function declarationFor(routineKey: string) { + const declaration = options.manifest?.routines?.find((routine) => routine.routineKey === routineKey); + if (!declaration) { + throw notFound(`Managed routine declaration not found: ${routineKey}`); + } + return declaration; + } + + async function getBinding(companyId: string, routineKey: string) { + return db + .select({ + id: pluginManagedResources.id, + companyId: pluginManagedResources.companyId, + pluginId: pluginManagedResources.pluginId, + pluginKey: pluginManagedResources.pluginKey, + resourceKind: pluginManagedResources.resourceKind, + resourceKey: pluginManagedResources.resourceKey, + resourceId: pluginManagedResources.resourceId, + defaultsJson: pluginManagedResources.defaultsJson, + manifestJson: plugins.manifestJson, + createdAt: pluginManagedResources.createdAt, + updatedAt: pluginManagedResources.updatedAt, + }) + .from(pluginManagedResources) + .innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id)) + .where( + and( + eq(pluginManagedResources.companyId, companyId), + eq(pluginManagedResources.pluginId, options.pluginId), + eq(pluginManagedResources.resourceKind, MANAGED_ROUTINE_RESOURCE_KIND), + eq(pluginManagedResources.resourceKey, routineKey), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function upsertBinding( + companyId: string, + declaration: PluginManagedRoutineDeclaration, + routineId: string, + ) { + const defaultsJson = buildRoutineDefaults(declaration); + const existing = await getBinding(companyId, declaration.routineKey); + if (existing) { + return db + .update(pluginManagedResources) + .set({ + resourceId: routineId, + defaultsJson, + updatedAt: new Date(), + }) + .where(eq(pluginManagedResources.id, existing.id)) + .returning() + .then((rows) => rows[0]); + } + return db + .insert(pluginManagedResources) + .values({ + companyId, + pluginId: options.pluginId, + pluginKey: options.pluginKey, + resourceKind: MANAGED_ROUTINE_RESOURCE_KIND, + resourceKey: declaration.routineKey, + resourceId: routineId, + defaultsJson, + }) + .returning() + .then((rows) => rows[0]); + } + + async function getRoutineWithManagedBy(companyId: string, declaration: PluginManagedRoutineDeclaration) { + const binding = await getBinding(companyId, declaration.routineKey); + if (!binding) return null; + const routine = await db + .select() + .from(routines) + .where(and(eq(routines.companyId, companyId), eq(routines.id, binding.resourceId))) + .then((rows) => rows[0] ?? null); + if (!routine) return null; + return { + ...routine, + managedByPlugin: managedByPlugin(binding), + } as Routine; + } + + async function resolveAgentId( + companyId: string, + declaration: PluginManagedRoutineDeclaration, + overrides?: RoutineOverrides, + ) { + if (overrides?.assigneeAgentId !== undefined) { + if (!overrides.assigneeAgentId) return { agentId: null, missingRef: null }; + const row = await db + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, overrides.assigneeAgentId))) + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Assignee agent not found"); + return { agentId: row.id, missingRef: null }; + } + + const ref = normalizeRef(options.pluginKey, declaration.assigneeRef, "agent"); + if (!ref) return { agentId: null, missingRef: null }; + const binding = await db + .select({ resourceId: pluginManagedResources.resourceId }) + .from(pluginManagedResources) + .where( + and( + eq(pluginManagedResources.companyId, companyId), + eq(pluginManagedResources.pluginId, options.pluginId), + eq(pluginManagedResources.resourceKind, "agent"), + eq(pluginManagedResources.resourceKey, ref.resourceKey), + ), + ) + .then((rows) => rows[0] ?? null); + if (!binding) return { agentId: null, missingRef: ref }; + const row = await db + .select({ id: agents.id }) + .from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, binding.resourceId))) + .then((rows) => rows[0] ?? null); + return row ? { agentId: row.id, missingRef: null } : { agentId: null, missingRef: ref }; + } + + async function resolveProjectId( + companyId: string, + declaration: PluginManagedRoutineDeclaration, + overrides?: RoutineOverrides, + ) { + if (overrides?.projectId !== undefined) { + if (!overrides.projectId) return { projectId: null, missingRef: null }; + const row = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.companyId, companyId), eq(projects.id, overrides.projectId))) + .then((rows) => rows[0] ?? null); + if (!row) throw notFound("Project not found"); + return { projectId: row.id, missingRef: null }; + } + + const ref = normalizeRef(options.pluginKey, declaration.projectRef, "project"); + if (!ref) return { projectId: null, missingRef: null }; + const binding = await db + .select({ resourceId: pluginManagedResources.resourceId }) + .from(pluginManagedResources) + .where( + and( + eq(pluginManagedResources.companyId, companyId), + eq(pluginManagedResources.pluginId, options.pluginId), + eq(pluginManagedResources.resourceKind, "project"), + eq(pluginManagedResources.resourceKey, ref.resourceKey), + ), + ) + .then((rows) => rows[0] ?? null); + if (!binding) return { projectId: null, missingRef: ref }; + const row = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.companyId, companyId), eq(projects.id, binding.resourceId))) + .then((rows) => rows[0] ?? null); + return row ? { projectId: row.id, missingRef: null } : { projectId: null, missingRef: ref }; + } + + async function resolveRefs( + companyId: string, + declaration: PluginManagedRoutineDeclaration, + overrides?: RoutineOverrides, + ) { + const [agent, project] = await Promise.all([ + resolveAgentId(companyId, declaration, overrides), + resolveProjectId(companyId, declaration, overrides), + ]); + const missingRefs: PluginManagedResourceRef[] = []; + if (agent.missingRef) missingRefs.push(agent.missingRef); + if (project.missingRef) missingRefs.push(project.missingRef); + return { + assigneeAgentId: agent.agentId, + projectId: project.projectId, + missingRefs, + }; + } + + function resolution( + companyId: string, + declaration: PluginManagedRoutineDeclaration, + routine: Routine | null, + status: PluginManagedRoutineResolution["status"], + missingRefs: PluginManagedResourceRef[] = [], + ): PluginManagedRoutineResolution { + return { + pluginKey: options.pluginKey, + resourceKind: "routine", + resourceKey: declaration.routineKey, + companyId, + routineId: routine?.id ?? null, + routine, + status, + missingRefs, + }; + } + + async function ensureDefaultTriggers( + routineId: string, + declaration: PluginManagedRoutineDeclaration, + ) { + const triggers = declaration.triggers ?? []; + if (triggers.length === 0) return; + const existingCount = await db + .select({ id: routineTriggers.id }) + .from(routineTriggers) + .where(eq(routineTriggers.routineId, routineId)) + .limit(1) + .then((rows) => rows.length); + if (existingCount > 0) return; + + for (const trigger of triggers) { + await routinesSvc.createTrigger(routineId, triggerInput(trigger), { agentId: null, userId: null }); + } + } + + async function createManagedRoutine( + companyId: string, + declaration: PluginManagedRoutineDeclaration, + overrides?: RoutineOverrides, + ) { + const refs = await resolveRefs(companyId, declaration, overrides); + if (refs.missingRefs.length > 0) { + return resolution(companyId, declaration, null, "missing_refs", refs.missingRefs); + } + + const created = await routinesSvc.create(companyId, { + projectId: refs.projectId, + goalId: declaration.goalId ?? null, + title: declaration.title, + description: declaration.description ?? null, + assigneeAgentId: refs.assigneeAgentId, + priority: declaration.priority ?? "medium", + status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"), + concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", + catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + variables: declaration.variables ?? [], + }, { agentId: null, userId: null }); + await upsertBinding(companyId, declaration, created.id); + await ensureDefaultTriggers(created.id, declaration); + const routine = await getRoutineWithManagedBy(companyId, declaration); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_routine.created", + entityType: "routine", + entityId: created.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.routineKey, + assigneeAgentId: refs.assigneeAgentId, + projectId: refs.projectId, + }, + }); + return resolution(companyId, declaration, routine, "created"); + } + + async function get(routineKey: string, companyId: string) { + const declaration = declarationFor(routineKey); + const routine = await getRoutineWithManagedBy(companyId, declaration); + return resolution(companyId, declaration, routine, routine ? "resolved" : "missing"); + } + + async function reconcile(routineKey: string, companyId: string, overrides?: RoutineOverrides) { + const declaration = declarationFor(routineKey); + const current = await get(routineKey, companyId); + if (current.routine) { + await upsertBinding(companyId, declaration, current.routine.id); + await ensureDefaultTriggers(current.routine.id, declaration); + return current; + } + return createManagedRoutine(companyId, declaration, overrides); + } + + async function reset(routineKey: string, companyId: string, overrides?: RoutineOverrides) { + const declaration = declarationFor(routineKey); + const current = await get(routineKey, companyId); + if (!current.routine) { + return createManagedRoutine(companyId, declaration, overrides); + } + + const refs = await resolveRefs(companyId, declaration, overrides); + if (refs.missingRefs.length > 0) { + return resolution(companyId, declaration, current.routine, "missing_refs", refs.missingRefs); + } + const updated = await routinesSvc.update(current.routine.id, { + projectId: refs.projectId, + goalId: declaration.goalId ?? null, + title: declaration.title, + description: declaration.description ?? null, + assigneeAgentId: refs.assigneeAgentId, + priority: declaration.priority ?? "medium", + status: declaration.status ?? (refs.assigneeAgentId ? "active" : "paused"), + concurrencyPolicy: declaration.concurrencyPolicy ?? "coalesce_if_active", + catchUpPolicy: declaration.catchUpPolicy ?? "skip_missed", + variables: declaration.variables ?? [], + }, { agentId: null, userId: null }); + if (!updated) throw notFound("Managed routine not found"); + await upsertBinding(companyId, declaration, updated.id); + await ensureDefaultTriggers(updated.id, declaration); + const routine = await getRoutineWithManagedBy(companyId, declaration); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_routine.reset", + entityType: "routine", + entityId: updated.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.routineKey, + assigneeAgentId: refs.assigneeAgentId, + projectId: refs.projectId, + }, + }); + return resolution(companyId, declaration, routine, "reset"); + } + + async function update( + routineKey: string, + companyId: string, + patch: { status?: string }, + ) { + const declaration = declarationFor(routineKey); + const current = await get(routineKey, companyId); + if (!current.routine) throw notFound("Managed routine not found"); + const updatePatch: { status?: RoutineStatus } = {}; + if (patch.status !== undefined) { + if (!ROUTINE_STATUSES.includes(patch.status as RoutineStatus)) { + throw unprocessable("Invalid routine status"); + } + updatePatch.status = patch.status as RoutineStatus; + } + const updated = await routinesSvc.update(current.routine.id, updatePatch, { agentId: null, userId: null }); + if (!updated) throw notFound("Managed routine not found"); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_routine.updated", + entityType: "routine", + entityId: updated.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.routineKey, + status: updated.status, + }, + }); + const routine = await getRoutineWithManagedBy(companyId, declaration); + return routine ?? updated; + } + + async function run(routineKey: string, companyId: string, overrides?: RoutineOverrides) { + const declaration = declarationFor(routineKey); + const current = await get(routineKey, companyId); + if (!current.routine) throw notFound("Managed routine not found"); + const run = await routinesSvc.runRoutine(current.routine.id, { + source: "manual", + assigneeAgentId: overrides?.assigneeAgentId, + projectId: overrides?.projectId, + }, { agentId: null, userId: null }); + await logActivity(db, { + companyId, + actorType: "plugin", + actorId: options.pluginId, + action: "plugin.managed_routine.run_triggered", + entityType: "routine_run", + entityId: run.id, + details: { + sourcePluginKey: options.pluginKey, + managedResourceKey: declaration.routineKey, + routineId: current.routine.id, + status: run.status, + }, + }); + return run; + } + + return { + get, + reconcile, + reset, + update, + run, + }; +} diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 79859a4e946..ce544a9cfb5 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db"; import { plugins, pluginConfig, + pluginCompanySettings, pluginEntities, pluginJobs, pluginJobRuns, @@ -15,6 +16,7 @@ import type { UpdatePluginStatus, UpsertPluginConfig, PatchPluginConfig, + PluginCompanySettings, PluginEntityRecord, PluginEntityQuery, PluginJobRecord, @@ -387,6 +389,64 @@ export function pluginRegistryService(db: Db) { return rows[0] ?? null; }, + // ----- Company settings ---------------------------------------------- + + /** Retrieve company-scoped plugin settings. */ + getCompanySettings: (pluginId: string, companyId: string): Promise => + db + .select() + .from(pluginCompanySettings) + .where(and( + eq(pluginCompanySettings.pluginId, pluginId), + eq(pluginCompanySettings.companyId, companyId), + )) + .then((rows) => rows[0] ?? null) as Promise, + + /** Create or replace company-scoped plugin settings. */ + upsertCompanySettings: async ( + pluginId: string, + companyId: string, + input: { enabled?: boolean; settingsJson: Record; lastError?: string | null }, + ): Promise => { + const plugin = await getById(pluginId); + if (!plugin) throw notFound("Plugin not found"); + + const existing = await db + .select() + .from(pluginCompanySettings) + .where(and( + eq(pluginCompanySettings.pluginId, pluginId), + eq(pluginCompanySettings.companyId, companyId), + )) + .then((rows) => rows[0] ?? null); + + if (existing) { + return db + .update(pluginCompanySettings) + .set({ + enabled: input.enabled ?? existing.enabled, + settingsJson: input.settingsJson, + lastError: input.lastError ?? null, + updatedAt: new Date(), + }) + .where(eq(pluginCompanySettings.id, existing.id)) + .returning() + .then((rows) => rows[0]) as Promise; + } + + return db + .insert(pluginCompanySettings) + .values({ + pluginId, + companyId, + enabled: input.enabled ?? true, + settingsJson: input.settingsJson, + lastError: input.lastError ?? null, + }) + .returning() + .then((rows) => rows[0]) as Promise; + }, + // ----- Entities ------------------------------------------------------- /** diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 2bcf7affe3f..4d568c68706 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,6 +1,14 @@ import { and, asc, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { projects, projectGoals, goals, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import { + projects, + projectGoals, + goals, + pluginManagedResources, + plugins, + projectWorkspaces, + workspaceRuntimeServices, +} from "@paperclipai/db"; import { PROJECT_COLORS, deriveProjectUrlKey, @@ -10,9 +18,12 @@ import { type ProjectCodebase, type ProjectExecutionWorkspacePolicy, type ProjectGoalRef, + type ProjectManagedByPlugin, type ProjectWorkspaceRuntimeConfig, type ProjectWorkspace, type WorkspaceRuntimeService, + type PluginManagedProjectDeclaration, + type PluginManagedProjectResolution, } from "@paperclipai/shared"; import { listCurrentRuntimeServicesForProjectWorkspaces } from "./workspace-runtime-read-model.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; @@ -50,6 +61,7 @@ interface ProjectWithGoals extends Omit codebase: ProjectCodebase; workspaces: ProjectWorkspace[]; primaryWorkspace: ProjectWorkspace | null; + managedByPlugin: ProjectManagedByPlugin | null; } interface ProjectShortnameRow { @@ -245,6 +257,40 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise(); + for (const row of managedRows) { + managedByProjectId.set(row.resourceId, { + id: row.id, + pluginId: row.pluginId, + pluginKey: row.pluginKey, + pluginDisplayName: row.manifestJson.displayName ?? row.pluginKey, + resourceKind: "project", + resourceKey: row.resourceKey, + defaultsJson: row.defaultsJson, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + } + return rows.map((row) => { const projectWorkspaceRows = map.get(row.id) ?? []; const workspaces = projectWorkspaceRows.map((workspace) => @@ -264,6 +310,7 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise & { goalIds?: string[] }, + ): Promise => { + const { goalIds: inputGoalIds, ...projectData } = data; + const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); + + // Auto-assign a color from the palette if none provided + if (!projectData.color) { + const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId)); + const usedColors = new Set(existing.map((r) => r.color).filter(Boolean)); + const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length]; + projectData.color = nextColor; + } + + const existingProjects = await db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, companyId)); + projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects); + + // Also write goalId to the legacy column (first goal or null) + const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; + + const row = await db + .insert(projects) + .values({ ...projectData, goalId: legacyGoalId, companyId }) + .returning() + .then((rows) => rows[0]); + + if (ids && ids.length > 0) { + await syncGoalLinks(db, row.id, companyId, ids); + } + + const [withGoals] = await attachGoals(db, [row]); + const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; + return enriched!; + }; + + const getProjectById = async (id: string): Promise => { + const row = await db + .select() + .from(projects) + .where(eq(projects.id, id)) + .then((rows) => rows[0] ?? null); + if (!row) return null; + const [withGoals] = await attachGoals(db, [row]); + if (!withGoals) return null; + const [enriched] = await attachWorkspaces(db, [withGoals]); + return enriched ?? null; + }; + return { list: async (companyId: string): Promise => { const rows = await db.select().from(projects).where(eq(projects.companyId, companyId)); @@ -418,58 +528,170 @@ export function projectService(db: Db) { return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project)); }, - getById: async (id: string): Promise => { - const row = await db - .select() - .from(projects) - .where(eq(projects.id, id)) + getById: getProjectById, + + resolveManagedProject: async (input: { + companyId: string; + pluginId: string; + pluginKey: string; + projectKey: string; + reset?: boolean; + createIfMissing?: boolean; + }): Promise => { + const plugin = await db + .select({ id: plugins.id, pluginKey: plugins.pluginKey, manifestJson: plugins.manifestJson }) + .from(plugins) + .where(eq(plugins.id, input.pluginId)) .then((rows) => rows[0] ?? null); - if (!row) return null; - const [withGoals] = await attachGoals(db, [row]); - if (!withGoals) return null; - const [enriched] = await attachWorkspaces(db, [withGoals]); - return enriched ?? null; - }, - - create: async ( - companyId: string, - data: Omit & { goalIds?: string[] }, - ): Promise => { - const { goalIds: inputGoalIds, ...projectData } = data; - const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId }); - - // Auto-assign a color from the palette if none provided - if (!projectData.color) { - const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId)); - const usedColors = new Set(existing.map((r) => r.color).filter(Boolean)); - const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length]; - projectData.color = nextColor; + if (!plugin || plugin.pluginKey !== input.pluginKey) { + return { + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + companyId: input.companyId, + projectId: null, + project: null, + status: "missing", + }; } - const existingProjects = await db - .select({ id: projects.id, name: projects.name }) - .from(projects) - .where(eq(projects.companyId, companyId)); - projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects); - - // Also write goalId to the legacy column (first goal or null) - const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null; - - const row = await db - .insert(projects) - .values({ ...projectData, goalId: legacyGoalId, companyId }) - .returning() - .then((rows) => rows[0]); - - if (ids && ids.length > 0) { - await syncGoalLinks(db, row.id, companyId, ids); + const declaration = plugin.manifestJson.projects?.find((project) => project.projectKey === input.projectKey); + if (!declaration) { + return { + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + companyId: input.companyId, + projectId: null, + project: null, + status: "missing", + }; } - const [withGoals] = await attachGoals(db, [row]); - const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : []; - return enriched!; + const defaults = buildManagedProjectDefaults(declaration); + const existingBinding = await db + .select() + .from(pluginManagedResources) + .where(and( + eq(pluginManagedResources.companyId, input.companyId), + eq(pluginManagedResources.pluginId, input.pluginId), + eq(pluginManagedResources.resourceKind, "project"), + eq(pluginManagedResources.resourceKey, input.projectKey), + )) + .then((rows) => rows[0] ?? null); + + if (existingBinding) { + const existingProject = await db + .select({ id: projects.id }) + .from(projects) + .where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId))) + .then((rows) => rows[0] ?? null); + if (existingProject) { + if (input.reset) { + await db + .update(projects) + .set({ + name: declaration.displayName, + description: declaration.description ?? null, + status: declaration.status ?? "in_progress", + color: declaration.color ?? null, + updatedAt: new Date(), + }) + .where(and(eq(projects.companyId, input.companyId), eq(projects.id, existingBinding.resourceId))); + } + if (input.createIfMissing !== false) { + await db + .update(pluginManagedResources) + .set({ defaultsJson: defaults, updatedAt: new Date() }) + .where(eq(pluginManagedResources.id, existingBinding.id)); + } + const project = await getProjectById(existingBinding.resourceId); + return { + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + companyId: input.companyId, + projectId: project?.id ?? existingBinding.resourceId, + project: project as import("@paperclipai/shared").Project | null, + status: input.reset ? "reset" : "resolved", + }; + } + + if (input.createIfMissing === false) { + return { + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + companyId: input.companyId, + projectId: null, + project: null, + status: "missing", + }; + } + + const project = await createProject(input.companyId, { + name: declaration.displayName, + description: declaration.description ?? null, + status: declaration.status ?? "in_progress", + color: declaration.color ?? undefined, + }); + await db + .update(pluginManagedResources) + .set({ resourceId: project.id, defaultsJson: defaults, updatedAt: new Date() }) + .where(eq(pluginManagedResources.id, existingBinding.id)); + const hydrated = await getProjectById(project.id); + return { + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + companyId: input.companyId, + projectId: hydrated?.id ?? project.id, + project: hydrated as import("@paperclipai/shared").Project | null, + status: "relinked", + }; + } + + if (input.createIfMissing === false) { + return { + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + companyId: input.companyId, + projectId: null, + project: null, + status: "missing", + }; + } + + const project = await createProject(input.companyId, { + name: declaration.displayName, + description: declaration.description ?? null, + status: declaration.status ?? "in_progress", + color: declaration.color ?? undefined, + }); + await db.insert(pluginManagedResources).values({ + companyId: input.companyId, + pluginId: input.pluginId, + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + resourceId: project.id, + defaultsJson: defaults, + }); + const hydrated = await getProjectById(project.id); + return { + pluginKey: input.pluginKey, + resourceKind: "project", + resourceKey: input.projectKey, + companyId: input.companyId, + projectId: hydrated?.id ?? project.id, + project: hydrated as import("@paperclipai/shared").Project | null, + status: "created", + }; }, + create: createProject, + update: async ( id: string, data: Partial & { goalIds?: string[] }, diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index a632f7760ec..b1242624f9f 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -10,6 +10,8 @@ import { issueInboxArchives, issueReadStates, issues, + pluginManagedResources, + plugins, projects, routineRuns, routines, @@ -21,6 +23,7 @@ import type { Routine, RoutineDetail, RoutineListItem, + RoutineManagedByPlugin, RoutineRunSummary, RoutineTrigger, RoutineTriggerSecretMaterial, @@ -34,6 +37,7 @@ import { getBuiltinRoutineVariableValues, extractRoutineVariableNames, interpolateRoutineTemplate, + pluginOperationIssueOriginKind, stringifyRoutineVariableValue, syncRoutineVariablesWithTemplate, } from "@paperclipai/shared"; @@ -354,6 +358,16 @@ function createRoutineDispatchFingerprint(input: { return crypto.createHash("sha256").update(canonical).digest("hex"); } +function readManagedRoutineIssueTemplate(defaultsJson: Record | null | undefined) { + const value = defaultsJson?.issueTemplate; + if (!isPlainRecord(value)) return null; + return { + surfaceVisibility: typeof value.surfaceVisibility === "string" ? value.surfaceVisibility : null, + originId: typeof value.originId === "string" && value.originId.trim() ? value.originId.trim() : null, + billingCode: typeof value.billingCode === "string" && value.billingCode.trim() ? value.billingCode.trim() : null, + }; +} + function routineUsesWorkspaceBranch(routine: typeof routines.$inferSelect) { return (routine.variables ?? []).some((variable) => variable.name === WORKSPACE_BRANCH_ROUTINE_VARIABLE) || extractRoutineVariableNames([routine.title, routine.description]).includes(WORKSPACE_BRANCH_ROUTINE_VARIABLE); @@ -380,6 +394,63 @@ export function routineService( .then((rows) => rows[0] ?? null); } + async function getManagedRoutineBinding(routine: typeof routines.$inferSelect) { + return db + .select({ + pluginKey: pluginManagedResources.pluginKey, + defaultsJson: pluginManagedResources.defaultsJson, + manifestJson: plugins.manifestJson, + }) + .from(pluginManagedResources) + .innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id)) + .where( + and( + eq(pluginManagedResources.companyId, routine.companyId), + eq(pluginManagedResources.resourceKind, "routine"), + eq(pluginManagedResources.resourceId, routine.id), + ), + ) + .then((rows) => rows[0] ?? null); + } + + async function listManagedRoutineMetadata(routineIds: string[]) { + if (routineIds.length === 0) return new Map(); + const rows = await db + .select({ + id: pluginManagedResources.id, + pluginId: pluginManagedResources.pluginId, + pluginKey: pluginManagedResources.pluginKey, + manifestJson: plugins.manifestJson, + resourceKey: pluginManagedResources.resourceKey, + resourceId: pluginManagedResources.resourceId, + defaultsJson: pluginManagedResources.defaultsJson, + createdAt: pluginManagedResources.createdAt, + updatedAt: pluginManagedResources.updatedAt, + }) + .from(pluginManagedResources) + .innerJoin(plugins, eq(pluginManagedResources.pluginId, plugins.id)) + .where( + and( + eq(pluginManagedResources.resourceKind, "routine"), + inArray(pluginManagedResources.resourceId, routineIds), + ), + ); + return new Map(rows.map((row) => [ + row.resourceId, + { + id: row.id, + pluginId: row.pluginId, + pluginKey: row.pluginKey, + pluginDisplayName: row.manifestJson.displayName ?? row.pluginKey, + resourceKind: "routine", + resourceKey: row.resourceKey, + defaultsJson: row.defaultsJson, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } satisfies RoutineManagedByPlugin, + ])); + } + async function getTriggerById(id: string) { return db .select() @@ -664,8 +735,11 @@ export function routineService( routine: typeof routines.$inferSelect, executor: Db = db, dispatchFingerprint?: string | null, + origin?: { kind: string; id: string | null }, ) { const fingerprintCondition = routineExecutionFingerprintCondition(dispatchFingerprint); + const originKind = origin?.kind ?? "routine_execution"; + const originId = origin?.id ?? routine.id; const executionBoundIssue = await executor .select() .from(issues) @@ -679,8 +753,8 @@ export function routineService( .where( and( eq(issues.companyId, routine.companyId), - eq(issues.originKind, "routine_execution"), - eq(issues.originId, routine.id), + eq(issues.originKind, originKind), + eq(issues.originId, originId), inArray(issues.status, OPEN_ISSUE_STATUSES), isNull(issues.hiddenAt), ...(fingerprintCondition ? [fingerprintCondition] : []), @@ -705,8 +779,8 @@ export function routineService( .where( and( eq(issues.companyId, routine.companyId), - eq(issues.originKind, "routine_execution"), - eq(issues.originId, routine.id), + eq(issues.originKind, originKind), + eq(issues.originId, originId), inArray(issues.status, OPEN_ISSUE_STATUSES), isNull(issues.hiddenAt), ...(fingerprintCondition ? [fingerprintCondition] : []), @@ -844,6 +918,13 @@ export function routineService( const title = interpolateRoutineTemplate(input.routine.title, allVariables) ?? input.routine.title; const description = interpolateRoutineTemplate(input.routine.description, allVariables); const triggerPayload = mergeRoutineRunPayload(input.payload, { ...automaticVariables, ...resolvedVariables }); + const managedRoutineBinding = await getManagedRoutineBinding(input.routine); + const managedIssueTemplate = readManagedRoutineIssueTemplate(managedRoutineBinding?.defaultsJson); + const issueOriginKind = managedIssueTemplate?.surfaceVisibility === "plugin_operation" && managedRoutineBinding + ? pluginOperationIssueOriginKind(managedRoutineBinding.pluginKey) + : "routine_execution"; + const issueOriginId = managedIssueTemplate?.originId ?? input.routine.id; + const issueBillingCode = managedIssueTemplate?.billingCode ?? null; const dispatchFingerprint = createRoutineDispatchFingerprint({ payload: triggerPayload, projectId, @@ -902,7 +983,10 @@ export function routineService( let createdIssue: Awaited> | null = null; try { - const activeIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint); + const activeIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint, { + kind: issueOriginKind, + id: issueOriginId, + }); if (activeIssue && input.routine.concurrencyPolicy !== "always_enqueue") { const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced"; if (manualRunnerUserId) { @@ -942,10 +1026,11 @@ export function routineService( assigneeAgentId, createdByAgentId: input.source === "manual" ? input.actor?.agentId ?? null : null, createdByUserId: manualRunnerUserId, - originKind: "routine_execution", - originId: input.routine.id, + originKind: issueOriginKind, + originId: issueOriginId, originRunId: createdRun.id, originFingerprint: dispatchFingerprint, + billingCode: issueBillingCode, executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, @@ -962,7 +1047,10 @@ export function routineService( throw error; } - const existingIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint); + const existingIssue = await findLiveExecutionIssue(input.routine, txDb, dispatchFingerprint, { + kind: issueOriginKind, + id: issueOriginId, + }); if (!existingIssue) throw error; const status = input.routine.concurrencyPolicy === "skip_if_active" ? "skipped" : "coalesced"; if (manualRunnerUserId) { @@ -1084,13 +1172,15 @@ export function routineService( .where(and(...conditions)) .orderBy(desc(routines.updatedAt), asc(routines.title)); const routineIds = rows.map((row) => row.id); - const [triggersByRoutine, latestRunByRoutine, activeIssueByRoutine] = await Promise.all([ + const [triggersByRoutine, latestRunByRoutine, activeIssueByRoutine, managedByRoutine] = await Promise.all([ listTriggersForRoutineIds(companyId, routineIds), listLatestRunByRoutineIds(companyId, routineIds), listLiveIssueByRoutineIds(companyId, routineIds), + listManagedRoutineMetadata(routineIds), ]); return rows.map((row) => ({ ...row, + managedByPlugin: managedByRoutine.get(row.id) ?? null, triggers: (triggersByRoutine.get(row.id) ?? []).map((trigger) => ({ id: trigger.id, kind: trigger.kind as RoutineListItem["triggers"][number]["kind"], @@ -1110,7 +1200,7 @@ export function routineService( getDetail: async (id: string): Promise => { const row = await getRoutineById(id); if (!row) return null; - const [project, assignee, parentIssue, triggers, recentRuns, activeIssue] = await Promise.all([ + const [project, assignee, parentIssue, triggers, recentRuns, activeIssue, managedByRoutine] = await Promise.all([ row.projectId ? db.select().from(projects).where(eq(projects.id, row.projectId)).then((rows) => rows[0] ?? null) : null, @@ -1189,10 +1279,12 @@ export function routineService( })), ), findLiveExecutionIssue(row), + listManagedRoutineMetadata([row.id]), ]); return { ...row, + managedByPlugin: managedByRoutine.get(row.id) ?? null, project, assignee, parentIssue, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index e29e6efe5c5..c9637a9c5d1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -129,7 +129,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> ); diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index acae3a71ce2..4bf96222640 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -43,6 +43,7 @@ export const issuesApi = { workspaceId?: string; executionWorkspaceId?: string; originKind?: string; + originKindPrefix?: string; originId?: string; descendantOf?: string; includeRoutineExecutions?: boolean; @@ -66,6 +67,7 @@ export const issuesApi = { if (filters?.workspaceId) params.set("workspaceId", filters.workspaceId); if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId); if (filters?.originKind) params.set("originKind", filters.originKind); + if (filters?.originKindPrefix) params.set("originKindPrefix", filters.originKindPrefix); if (filters?.originId) params.set("originId", filters.originId); if (filters?.descendantOf) params.set("descendantOf", filters.descendantOf); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); diff --git a/ui/src/api/plugins.test.ts b/ui/src/api/plugins.test.ts new file mode 100644 index 00000000000..20d1153b89f --- /dev/null +++ b/ui/src/api/plugins.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockApi = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), +})); + +vi.mock("./client", () => ({ + api: mockApi, +})); + +import { pluginsApi } from "./plugins"; + +describe("pluginsApi local folders", () => { + beforeEach(() => { + mockApi.get.mockReset(); + mockApi.post.mockReset(); + mockApi.put.mockReset(); + mockApi.get.mockResolvedValue({}); + mockApi.post.mockResolvedValue({}); + mockApi.put.mockResolvedValue({}); + }); + + it("lists company-scoped local folders for a plugin", async () => { + await pluginsApi.listLocalFolders("plugin-1", "company-1"); + + expect(mockApi.get).toHaveBeenCalledWith( + "/plugins/plugin-1/companies/company-1/local-folders", + ); + }); + + it("validates a candidate folder path without saving", async () => { + await pluginsApi.validateLocalFolder("plugin-1", "company-1", "wiki-root", { + path: "/tmp/wiki", + access: "readWrite", + requiredFiles: ["WIKI.md"], + }); + + expect(mockApi.post).toHaveBeenCalledWith( + "/plugins/plugin-1/companies/company-1/local-folders/wiki-root/validate", + { + path: "/tmp/wiki", + access: "readWrite", + requiredFiles: ["WIKI.md"], + }, + ); + }); + + it("saves through the local-folder PUT endpoint", async () => { + await pluginsApi.configureLocalFolder("plugin-1", "company-1", "wiki-root", { + path: "/tmp/wiki", + requiredDirectories: ["wiki"], + }); + + expect(mockApi.put).toHaveBeenCalledWith( + "/plugins/plugin-1/companies/company-1/local-folders/wiki-root", + { + path: "/tmp/wiki", + requiredDirectories: ["wiki"], + }, + ); + }); +}); diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index 0edc580f5fb..2e4c981af37 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -14,6 +14,7 @@ import type { PluginLauncherDeclaration, PluginLauncherRenderContextSnapshot, PluginUiSlotDeclaration, + PluginLocalFolderDeclaration, PluginRecord, PluginConfig, PluginStatus, @@ -140,6 +141,54 @@ export interface AvailablePluginExample { tag: "example"; } +export interface PluginLocalFolderProblem { + code: + | "not_configured" + | "not_absolute" + | "missing" + | "not_directory" + | "not_readable" + | "not_writable" + | "missing_directory" + | "missing_file" + | "path_traversal" + | "symlink_escape" + | "atomic_write_failed"; + message: string; + path?: string; +} + +export interface PluginLocalFolderStatus { + folderKey: string; + configured: boolean; + path: string | null; + realPath: string | null; + access: "read" | "readWrite"; + readable: boolean; + writable: boolean; + requiredDirectories: string[]; + requiredFiles: string[]; + missingDirectories: string[]; + missingFiles: string[]; + healthy: boolean; + problems: PluginLocalFolderProblem[]; + checkedAt: string; +} + +export interface PluginLocalFoldersResponse { + pluginId: string; + companyId: string; + declarations: PluginLocalFolderDeclaration[]; + folders: PluginLocalFolderStatus[]; +} + +export interface PluginLocalFolderSaveInput { + path: string; + access?: "read" | "readWrite"; + requiredDirectories?: string[]; + requiredFiles?: string[]; +} + /** * Plugin management API client. * @@ -337,6 +386,48 @@ export const pluginsApi = { testConfig: (pluginId: string, configJson: Record) => api.post<{ valid: boolean; message?: string }>(`/plugins/${pluginId}/config/test`, { configJson }), + /** + * List manifest-declared and stored company-scoped local folders for a plugin. + */ + listLocalFolders: (pluginId: string, companyId: string) => + api.get(`/plugins/${pluginId}/companies/${companyId}/local-folders`), + + /** + * Inspect a configured local folder without changing persisted settings. + */ + localFolderStatus: (pluginId: string, companyId: string, folderKey: string) => + api.get( + `/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/status`, + ), + + /** + * Validate a candidate local folder path without saving it. + */ + validateLocalFolder: ( + pluginId: string, + companyId: string, + folderKey: string, + input: PluginLocalFolderSaveInput, + ) => + api.post( + `/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}/validate`, + input, + ), + + /** + * Persist a company-scoped local folder path and return its inspected status. + */ + configureLocalFolder: ( + pluginId: string, + companyId: string, + folderKey: string, + input: PluginLocalFolderSaveInput, + ) => + api.put( + `/plugins/${pluginId}/companies/${companyId}/local-folders/${encodeURIComponent(folderKey)}`, + input, + ), + // =========================================================================== // Bridge proxy endpoints — used by the plugin UI bridge runtime // =========================================================================== diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index 4acebfec3a9..9515820113d 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -7,7 +7,6 @@ import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; import { SidebarNavItem } from "./SidebarNavItem"; -import { SidebarCompanyMenu } from "./SidebarCompanyMenu"; export function CompanySettingsSidebar() { const { selectedCompany, selectedCompanyId } = useCompany(); @@ -32,11 +31,8 @@ export function CompanySettingsSidebar() { }); return ( -
    @@ -558,6 +569,350 @@ export function PluginSettings() { ); } +// --------------------------------------------------------------------------- +// PluginLocalFoldersSettings — host-managed company-scoped folders +// --------------------------------------------------------------------------- + +interface PluginLocalFoldersSettingsProps { + pluginId: string; + companyId: string | null; + declarations: PluginLocalFolderDeclaration[]; +} + +function PluginLocalFoldersSettings({ pluginId, companyId, declarations }: PluginLocalFoldersSettingsProps) { + const { data, isLoading, error } = useQuery({ + queryKey: companyId + ? queryKeys.plugins.localFolders(pluginId, companyId) + : ["plugins", pluginId, "companies", "none", "local-folders"], + queryFn: () => pluginsApi.listLocalFolders(pluginId, companyId!), + enabled: !!companyId, + }); + + const statusByKey = new Map((data?.folders ?? []).map((folder) => [folder.folderKey, folder])); + + if (!companyId) { + return ( +
    + Select a company to configure this plugin's local folders. +
    + ); + } + + return ( +
    +
    + +

    Local folders

    +
    + {error ? ( +
    + {(error as Error).message || "Failed to load local folder settings."} +
    + ) : null} + {isLoading ? ( +
    + + Loading local folders... +
    + ) : ( +
    + {declarations.map((declaration) => ( + + ))} +
    + )} +
    + ); +} + +interface PluginLocalFolderRowProps { + pluginId: string; + companyId: string; + declaration: PluginLocalFolderDeclaration; + status?: PluginLocalFolderStatus; +} + +function PluginLocalFolderRow({ pluginId, companyId, declaration, status }: PluginLocalFolderRowProps) { + const queryClient = useQueryClient(); + const serverPath = status?.path ?? ""; + const [pathValue, setPathValue] = useState(serverPath); + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + + useEffect(() => { + setPathValue(serverPath); + setMessage(null); + }, [serverPath, declaration.folderKey]); + + const saveMutation = useMutation({ + mutationFn: (path: string) => + pluginsApi.configureLocalFolder(pluginId, companyId, declaration.folderKey, { + path, + access: declaration.access, + requiredDirectories: declaration.requiredDirectories, + requiredFiles: declaration.requiredFiles, + }), + onSuccess: (nextStatus) => { + setMessage({ + type: nextStatus.healthy ? "success" : "error", + text: nextStatus.healthy + ? "Local folder saved." + : "Local folder saved, but validation still needs attention.", + }); + queryClient.invalidateQueries({ queryKey: queryKeys.plugins.localFolders(pluginId, companyId) }); + }, + onError: (err: Error) => { + setMessage({ type: "error", text: err.message || "Failed to save local folder." }); + }, + }); + + const trimmedPath = pathValue.trim(); + const isDirty = trimmedPath !== serverPath; + const access = status?.access ?? declaration.access ?? "readWrite"; + + const handleSave = useCallback(() => { + if (!trimmedPath) { + setMessage({ type: "error", text: "Local folder path is required." }); + return; + } + if (!isLikelyAbsolutePath(trimmedPath)) { + setMessage({ type: "error", text: "Local folder must be a full absolute path." }); + return; + } + setMessage(null); + saveMutation.mutate(trimmedPath); + }, [saveMutation, trimmedPath]); + + return ( +
    +
    +
    +
    +

    {declaration.displayName}

    + + {declaration.folderKey} + + + {status?.healthy ? "Healthy" : "Needs attention"} + +
    + {declaration.description ? ( +

    + {declaration.description} +

    + ) : null} +
    + + {access === "readWrite" ? "Read/write" : "Read only"} + +
    + +
    + + + +
    + + {status?.path ? ( +
    +
    Configured path
    +
    + {status.path} +
    +
    + ) : null} + +
    + +
    + { + setPathValue(event.target.value); + setMessage(null); + }} + placeholder="/absolute/path/to/folder" + /> + + +
    +
    + + + + {status?.problems?.length ? ( +
    +
    Validation problems
    +
      + {status.problems.map((problem, index) => ( +
    • + {problem.message} + {problem.path ? {problem.path} : null} +
    • + ))} +
    +
    + ) : null} + + {message ? ( +
    + {message.text} +
    + ) : null} +
    + ); +} + +function FolderStatusMetric({ label, value, ok }: { label: string; value: string; ok: boolean }) { + return ( +
    + {label} + {value} +
    + ); +} + +function FolderRequirements({ + status, + declaration, +}: { + status?: PluginLocalFolderStatus; + declaration: PluginLocalFolderDeclaration; +}) { + const requiredDirectories = status?.requiredDirectories ?? declaration.requiredDirectories ?? []; + const requiredFiles = status?.requiredFiles ?? declaration.requiredFiles ?? []; + const missingDirectories = status?.missingDirectories ?? requiredDirectories; + const missingFiles = status?.missingFiles ?? requiredFiles; + const rootNotInspected = isRootNotInspected(status); + + if (requiredDirectories.length === 0 && requiredFiles.length === 0) return null; + + return ( +
    + + +
    + ); +} + +function isRootNotInspected(status?: PluginLocalFolderStatus) { + if (!status?.configured || status.readable) return false; + return status.problems.some((problem) => + problem.code === "missing" || problem.code === "not_readable" || problem.code === "not_directory" + ); +} + +function RequirementList({ + title, + items, + missingItems, + missingLabel, + inspectionUnavailable, +}: { + title: string; + items: string[]; + missingItems: string[]; + missingLabel: string; + inspectionUnavailable?: boolean; +}) { + return ( +
    +
    + {title} + {inspectionUnavailable ? ( + + Not inspected + + ) : missingItems.length > 0 ? ( + + {missingItems.length} missing + + ) : ( + Present + )} +
    + {items.length > 0 ? ( +
    + {items.map((item) => { + const missing = missingItems.includes(item); + return ( + + {item} + + ); + })} +
    + ) : ( +

    None declared.

    + )} + {inspectionUnavailable ? ( +

    Configured root was not inspected.

    + ) : missingItems.length > 0 ? ( +

    {missingLabel}: {missingItems.join(", ")}

    + ) : null} +
    + ); +} + +function isLikelyAbsolutePath(pathValue: string) { + return ( + pathValue.startsWith("/") || + /^[A-Za-z]:[\\/]/.test(pathValue) || + pathValue.startsWith("\\\\") + ); +} + // --------------------------------------------------------------------------- // PluginConfigForm — auto-generated form for instanceConfigSchema // --------------------------------------------------------------------------- diff --git a/ui/src/pages/ProjectDetail.test.tsx b/ui/src/pages/ProjectDetail.test.tsx new file mode 100644 index 00000000000..fce062c2cd3 --- /dev/null +++ b/ui/src/pages/ProjectDetail.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { Project } from "@paperclipai/shared"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectDetail } from "./ProjectDetail"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const mockProjectsApi = vi.hoisted(() => ({ + get: vi.fn(), + list: vi.fn(), + update: vi.fn(), +})); +const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), + update: vi.fn(), +})); +const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() })); +const mockBudgetsApi = vi.hoisted(() => ({ overview: vi.fn(), upsertPolicy: vi.fn() })); +const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn() })); +const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() })); +const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() })); +const mockNavigate = vi.hoisted(() => vi.fn()); +const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); +const mockIssuesList = vi.hoisted(() => vi.fn()); + +vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); +vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi })); +vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi })); +vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi })); +vi.mock("../api/budgets", () => ({ budgetsApi: mockBudgetsApi })); +vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi })); +vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi })); +vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi })); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, + Navigate: ({ to }: { to: string }) =>
    {to}
    , + useLocation: () => ({ pathname: "/projects/project-1/plugin-operations", search: "", hash: "", state: null }), + useNavigate: () => mockNavigate, + useParams: () => ({ projectId: "project-1" }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [{ id: "company-1", issuePrefix: "PAP" }], + selectedCompanyId: "company-1", + setSelectedCompanyId: vi.fn(), + }), +})); +vi.mock("../context/PanelContext", () => ({ usePanel: () => ({ closePanel: vi.fn() }) })); +vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) })); +vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) })); +vi.mock("@/plugins/slots", () => ({ + PluginSlotMount: () => null, + PluginSlotOutlet: () => null, + usePluginSlots: () => ({ slots: [], isLoading: false }), +})); +vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null })); +vi.mock("../components/ProjectProperties", () => ({ + ProjectProperties: () =>
    , +})); +vi.mock("../components/BudgetPolicyCard", () => ({ + BudgetPolicyCard: () =>
    , +})); +vi.mock("../components/InlineEditor", () => ({ + InlineEditor: ({ value, placeholder }: { value?: string; placeholder?: string }) => ( + {value || placeholder || null} + ), +})); +vi.mock("../components/ProjectWorkspacesContent", () => ({ + ProjectWorkspacesContent: () =>
    , +})); +vi.mock("../components/PageTabBar", () => ({ + PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => ( +
    {items.map((item) => )}
    + ), +})); +vi.mock("../components/IssuesList", () => ({ + IssuesList: (props: unknown) => { + mockIssuesList(props); + return
    ; + }, +})); + +function project(overrides: Partial = {}): Project { + const now = new Date("2026-05-01T00:00:00Z"); + return { + id: "project-1", + companyId: "company-1", + urlKey: "project-1", + goalId: null, + goalIds: [], + goals: [], + name: "Managed Project", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: "#14b8a6", + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: null, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: null, + managedFolder: "/tmp/project-1", + effectiveLocalFolder: "/tmp/project-1", + origin: "managed_checkout", + }, + workspaces: [], + primaryWorkspace: null, + managedByPlugin: { + id: "managed-1", + pluginId: "plugin-1", + pluginKey: "paperclip.missions", + pluginDisplayName: "Missions", + resourceKind: "project", + resourceKey: "operations", + defaultsJson: {}, + createdAt: now, + updatedAt: now, + }, + archivedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; +} + +describe("ProjectDetail", () => { + let root: Root | null = null; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockProjectsApi.get.mockResolvedValue(project()); + mockProjectsApi.list.mockResolvedValue([project()]); + mockIssuesApi.list.mockResolvedValue([]); + mockAgentsApi.list.mockResolvedValue([]); + mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + mockBudgetsApi.overview.mockResolvedValue({ policies: [] }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + mockExecutionWorkspacesApi.list.mockResolvedValue([]); + }); + + afterEach(() => { + act(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + it("shows managed plugin affordances and filters the operations tab by plugin origin", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(container.textContent).toContain("Managed by Missions"); + expect(container.textContent).toContain("Plugin operations"); + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { + projectId: "project-1", + originKindPrefix: "plugin:paperclip.missions", + }); + }); +}); diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index e49f066839a..fb0f053bb4f 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -33,7 +33,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo /* ── Top-level tab types ── */ -type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget"; +type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; @@ -50,6 +50,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu if (tab === "configuration") return "configuration"; if (tab === "budget") return "budget"; if (tab === "issues") return "list"; + if (tab === "plugin-operations") return "plugin-operations"; if (tab === "workspaces") return "workspaces"; return null; } @@ -208,6 +209,67 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan ); } +function ProjectPluginOperationsList({ + projectId, + companyId, + pluginKey, +}: { + projectId: string; + companyId: string; + pluginKey: string; +}) { + const queryClient = useQueryClient(); + const originKindPrefix = `plugin:${pluginKey}`; + + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(companyId), + queryFn: () => agentsApi.list(companyId), + enabled: !!companyId, + }); + const { data: projects } = useQuery({ + queryKey: queryKeys.projects.list(companyId), + queryFn: () => projectsApi.list(companyId), + enabled: !!companyId, + }); + const { data: liveRuns } = useQuery({ + queryKey: queryKeys.liveRuns(companyId), + queryFn: () => heartbeatsApi.liveRunsForCompany(companyId), + enabled: !!companyId, + refetchInterval: 5000, + }); + const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]); + + const { data: issues, isLoading, error } = useQuery({ + queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix), + queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }), + enabled: !!companyId && !!projectId, + }); + + const updateIssue = useMutation({ + mutationFn: ({ id, data }: { id: string; data: Record }) => + issuesApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + }, + }); + + return ( + updateIssue.mutate({ id, data })} + /> + ); +} + /* ── Main project page ── */ export function ProjectDetail() { @@ -390,6 +452,10 @@ export function ProjectDetail() { navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true }); return; } + if (activeTab === "plugin-operations") { + navigate(`/projects/${canonicalProjectRef}/plugin-operations`, { replace: true }); + return; + } if (activeTab === "workspaces") { navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true }); return; @@ -523,6 +589,9 @@ export function ProjectDetail() { if (cachedTab === "budget") { return ; } + if (cachedTab === "plugin-operations" && project?.managedByPlugin) { + return ; + } if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) { return ; } @@ -554,6 +623,8 @@ export function ProjectDetail() { navigate(`/projects/${canonicalProjectRef}/workspaces`); } else if (tab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`); + } else if (tab === "plugin-operations") { + navigate(`/projects/${canonicalProjectRef}/plugin-operations`); } else if (tab === "configuration") { navigate(`/projects/${canonicalProjectRef}/configuration`); } else { @@ -583,6 +654,12 @@ export function ProjectDetail() { Paused by budget hard stop
    ) : null} + {project.managedByPlugin ? ( +
    + + Managed by {project.managedByPlugin.pluginDisplayName} +
    + ) : null}
    @@ -622,6 +699,7 @@ export function ProjectDetail() { items={[ { value: "list", label: "Issues" }, { value: "overview", label: "Overview" }, + ...(project.managedByPlugin ? [{ value: "plugin-operations", label: "Plugin operations" }] : []), ...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []), { value: "configuration", label: "Configuration" }, { value: "budget", label: "Budget" }, @@ -651,6 +729,14 @@ export function ProjectDetail() { )} + {activeTab === "plugin-operations" && project?.id && resolvedCompanyId && project.managedByPlugin && ( + + )} + {activeTab === "workspaces" ? ( workspaceTabDecisionLoaded ? ( workspaceTabError ? ( diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index bb36f1d48eb..a2af906ff29 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -702,36 +702,44 @@ export function RoutineDetail() {
    {/* Header: editable title + actions */}
    -