diff --git a/skills/create-agent-adapter/SKILL.md b/.agents/skills/create-agent-adapter/SKILL.md similarity index 100% rename from skills/create-agent-adapter/SKILL.md rename to .agents/skills/create-agent-adapter/SKILL.md diff --git a/skills/pr-report/SKILL.md b/.agents/skills/pr-report/SKILL.md similarity index 100% rename from skills/pr-report/SKILL.md rename to .agents/skills/pr-report/SKILL.md diff --git a/skills/pr-report/assets/html-report-starter.html b/.agents/skills/pr-report/assets/html-report-starter.html similarity index 100% rename from skills/pr-report/assets/html-report-starter.html rename to .agents/skills/pr-report/assets/html-report-starter.html diff --git a/skills/pr-report/references/style-guide.md b/.agents/skills/pr-report/references/style-guide.md similarity index 100% rename from skills/pr-report/references/style-guide.md rename to .agents/skills/pr-report/references/style-guide.md diff --git a/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md similarity index 100% rename from skills/release-changelog/SKILL.md rename to .agents/skills/release-changelog/SKILL.md diff --git a/skills/release/SKILL.md b/.agents/skills/release/SKILL.md similarity index 99% rename from skills/release/SKILL.md rename to .agents/skills/release/SKILL.md index 5f39ba76..2eac6ad8 100644 --- a/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -33,7 +33,7 @@ Use this skill when leadership asks for: Before proceeding, verify all of the following: -1. `skills/release-changelog/SKILL.md` exists and is usable. +1. `.agents/skills/release-changelog/SKILL.md` exists and is usable. 2. The repo working tree is clean, including untracked files. 3. There are commits since the last stable tag. 4. The release SHA has passed the verification gate or is about to. diff --git a/AGENTS.md b/AGENTS.md index e4b5b514..dad6684f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,9 @@ If you change schema/API behavior, update all impacted layers: 4. Do not replace strategic docs wholesale unless asked. Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` aligned. +5. Keep plan docs dated and centralized. +New plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. + ## 6. Database Change Workflow When changing data model: diff --git a/Dockerfile b/Dockerfile index 3fe1f2b2..014113e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ COPY packages/adapter-utils/package.json packages/adapter-utils/ COPY packages/adapters/claude-local/package.json packages/adapters/claude-local/ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ +COPY packages/adapters/gemini-local/package.json packages/adapters/gemini-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ diff --git a/README.md b/README.md index c3d9fc8e..70ddee5f 100644 --- a/README.md +++ b/README.md @@ -248,8 +248,6 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details. - -
## Community diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 6bae020a..d261b8a8 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,5 +1,23 @@ # paperclipai +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + - @paperclipai/adapter-claude-local@0.3.1 + - @paperclipai/adapter-codex-local@0.3.1 + - @paperclipai/adapter-cursor-local@0.3.1 + - @paperclipai/adapter-gemini-local@0.3.1 + - @paperclipai/adapter-openclaw-gateway@0.3.1 + - @paperclipai/adapter-opencode-local@0.3.1 + - @paperclipai/adapter-pi-local@0.3.1 + - @paperclipai/db@0.3.1 + - @paperclipai/shared@0.3.1 + - @paperclipai/server@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/cli/package.json b/cli/package.json index 24a8bf66..4bda09ed 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "paperclipai", - "version": "0.3.0", + "version": "0.3.1", "description": "Paperclip CLI — orchestrate AI agent teams to run a business", "type": "module", "bin": { @@ -37,6 +37,7 @@ "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", + "@paperclipai/adapter-gemini-local": "workspace:*", "@paperclipai/adapter-opencode-local": "workspace:*", "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-openclaw-gateway": "workspace:*", diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts index 40bb1554..baf5db51 100644 --- a/cli/src/__tests__/agent-jwt-env.test.ts +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ensureAgentJwtSecret, + mergePaperclipEnvEntries, readAgentJwtSecretFromEnv, + readPaperclipEnvEntries, resolveAgentJwtEnvFile, } from "../config/env.js"; import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js"; @@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => { const result = agentJwtSecretCheck(configPath); expect(result.status).toBe("pass"); }); + + it("quotes hash-prefixed env values so dotenv round-trips them", () => { + const configPath = tempConfigPath(); + const envPath = resolveAgentJwtEnvFile(configPath); + + mergePaperclipEnvEntries( + { + PAPERCLIP_WORKTREE_COLOR: "#439edb", + }, + envPath, + ); + + const contents = fs.readFileSync(envPath, "utf-8"); + expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"'); + expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb"); + }); }); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 106cbc74..a8333ba5 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -2,19 +2,22 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd, + resolveSourceConfigPath, resolveGitWorktreeAddArgs, resolveWorktreeMakeTargetPath, + worktreeInitCommand, worktreeMakeCommand, } from "../commands/worktree.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, formatShellExports, + generateWorktreeColor, resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, rewriteLocalUrlPort, @@ -22,6 +25,20 @@ import { } from "../commands/worktree-lib.js"; import type { PaperclipConfig } from "../config/schema.js"; +const ORIGINAL_CWD = process.cwd(); +const ORIGINAL_ENV = { ...process.env }; + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) delete process.env[key]; + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + function buildSourceConfig(): PaperclipConfig { return { $meta: { @@ -115,6 +132,28 @@ describe("worktree helpers", () => { ).toEqual(["worktree", "add", "/tmp/feature-branch", "feature-branch"]); }); + it("builds git worktree add args with a start point", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: false, + startPoint: "public-gh/master", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "public-gh/master"]); + }); + + it("uses start point even when a local branch with the same name exists", () => { + expect( + resolveGitWorktreeAddArgs({ + branchName: "my-worktree", + targetPath: "/tmp/my-worktree", + branchExists: true, + startPoint: "origin/main", + }), + ).toEqual(["worktree", "add", "-b", "my-worktree", "/tmp/my-worktree", "origin/main"]); + }); + it("rewrites loopback auth URLs to the new port only", () => { expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); @@ -144,13 +183,22 @@ describe("worktree helpers", () => { path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), ); - const env = buildWorktreeEnvEntries(paths); + const env = buildWorktreeEnvEntries(paths, { + name: "feature-worktree-support", + color: "#3abf7a", + }); expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support"); + expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a"); expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); }); + it("generates vivid worktree colors as hex", () => { + expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); + }); + it("uses minimal seed mode to keep app state but drop heavy runtime history", () => { const minimal = resolveWorktreeSeedPlan("minimal"); const full = resolveWorktreeSeedPlan("full"); @@ -167,7 +215,11 @@ describe("worktree helpers", () => { it("copies the source local_encrypted secrets key into the seeded worktree instance", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-secrets-")); + const originalInlineMasterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + const originalKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; try { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; const sourceConfigPath = path.join(tempRoot, "source", "config.json"); const sourceKeyPath = path.join(tempRoot, "source", "secrets", "master.key"); const targetKeyPath = path.join(tempRoot, "target", "secrets", "master.key"); @@ -186,6 +238,16 @@ describe("worktree helpers", () => { expect(fs.readFileSync(targetKeyPath, "utf8")).toBe("source-master-key"); } finally { + if (originalInlineMasterKey === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY = originalInlineMasterKey; + } + if (originalKeyFile === undefined) { + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + } else { + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = originalKeyFile; + } fs.rmSync(tempRoot, { recursive: true, force: true }); } }); @@ -211,6 +273,92 @@ describe("worktree helpers", () => { } }); + it("persists the current agent jwt secret into the worktree env file", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-")); + const repoRoot = path.join(tempRoot, "repo"); + const originalCwd = process.cwd(); + const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret"; + process.chdir(repoRoot); + + await worktreeInitCommand({ + seed: false, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: path.join(tempRoot, ".paperclip-worktrees"), + }); + + const envPath = path.join(repoRoot, ".paperclip", ".env"); + const envContents = fs.readFileSync(envPath, "utf8"); + expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); + expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); + } finally { + process.chdir(originalCwd); + if (originalJwtSecret === undefined) { + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + } else { + process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("defaults the seed source config to the current repo-local Paperclip config", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); + const repoRoot = path.join(tempRoot, "repo"); + const localConfigPath = path.join(repoRoot, ".paperclip", "config.json"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); + fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath)); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("preserves the source config path across worktree:make cwd changes", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-")); + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "target"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(targetRoot); + + expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe( + path.resolve(sourceConfigPath), + ); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("rebinds same-repo workspace paths onto the current worktree root", () => { expect( rebindWorkspaceCwd({ @@ -293,7 +441,7 @@ describe("worktree helpers", () => { const fakeHome = path.join(tempRoot, "home"); const worktreePath = path.join(fakeHome, "paperclip-make-test"); const originalCwd = process.cwd(); - const originalHome = process.env.HOME; + const homedirSpy = vi.spyOn(os, "homedir").mockReturnValue(fakeHome); try { fs.mkdirSync(repoRoot, { recursive: true }); @@ -305,7 +453,6 @@ describe("worktree helpers", () => { execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" }); execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" }); - process.env.HOME = fakeHome; process.chdir(repoRoot); await worktreeMakeCommand("paperclip-make-test", { @@ -318,12 +465,8 @@ describe("worktree helpers", () => { expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true); } finally { process.chdir(originalCwd); - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } + homedirSpy.mockRestore(); fs.rmSync(tempRoot, { recursive: true, force: true }); } - }); + }, 20_000); }); diff --git a/cli/src/adapters/registry.ts b/cli/src/adapters/registry.ts index 21b915f5..e4443f55 100644 --- a/cli/src/adapters/registry.ts +++ b/cli/src/adapters/registry.ts @@ -2,6 +2,7 @@ import type { CLIAdapterModule } from "@paperclipai/adapter-utils"; import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; import { printCodexStreamEvent } from "@paperclipai/adapter-codex-local/cli"; import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli"; +import { printGeminiStreamEvent } from "@paperclipai/adapter-gemini-local/cli"; import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli"; import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli"; import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli"; @@ -33,6 +34,11 @@ const cursorLocalCLIAdapter: CLIAdapterModule = { formatStdoutEvent: printCursorStreamEvent, }; +const geminiLocalCLIAdapter: CLIAdapterModule = { + type: "gemini_local", + formatStdoutEvent: printGeminiStreamEvent, +}; + const openclawGatewayCLIAdapter: CLIAdapterModule = { type: "openclaw_gateway", formatStdoutEvent: printOpenClawGatewayStreamEvent, @@ -45,6 +51,7 @@ const adaptersByType = new Map( openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, + geminiLocalCLIAdapter, openclawGatewayCLIAdapter, processCLIAdapter, httpCLIAdapter, diff --git a/cli/src/commands/allowed-hostname.ts b/cli/src/commands/allowed-hostname.ts index 942c464b..d47a3bba 100644 --- a/cli/src/commands/allowed-hostname.ts +++ b/cli/src/commands/allowed-hostname.ts @@ -26,6 +26,9 @@ export async function addAllowedHostname(host: string, opts: { config?: string } p.log.info(`Hostname ${pc.cyan(normalized)} is already allowed.`); } else { p.log.success(`Added allowed hostname: ${pc.cyan(normalized)}`); + p.log.message( + pc.dim("Restart the Paperclip server for this change to take effect."), + ); } if (!(config.server.deploymentMode === "authenticated" && config.server.exposure === "private")) { diff --git a/cli/src/commands/client/agent.ts b/cli/src/commands/client/agent.ts index 36eb04e6..2c294628 100644 --- a/cli/src/commands/client/agent.ts +++ b/cli/src/commands/client/agent.ts @@ -1,5 +1,9 @@ import { Command } from "commander"; import type { Agent } from "@paperclipai/shared"; +import { + removeMaintainerOnlySkillSymlinks, + resolvePaperclipSkillsDir, +} from "@paperclipai/adapter-utils/server-utils"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -34,15 +38,12 @@ interface SkillsInstallSummary { tool: "codex" | "claude"; target: string; linked: string[]; + removed: string[]; skipped: string[]; failed: Array<{ name: string; error: string }>; } const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills - path.resolve(process.cwd(), "skills"), -]; function codexSkillsHome(): string { const fromEnv = process.env.CODEX_HOME?.trim(); @@ -56,14 +57,6 @@ function claudeSkillsHome(): string { return path.join(base, "skills"); } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - async function installSkillsForTarget( sourceSkillsDir: string, targetSkillsDir: string, @@ -73,20 +66,65 @@ async function installSkillsForTarget( tool, target: targetSkillsDir, linked: [], + removed: [], skipped: [], failed: [], }; await fs.mkdir(targetSkillsDir, { recursive: true }); const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true }); + summary.removed = await removeMaintainerOnlySkillSymlinks( + targetSkillsDir, + entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name), + ); for (const entry of entries) { if (!entry.isDirectory()) continue; const source = path.join(sourceSkillsDir, entry.name); const target = path.join(targetSkillsDir, entry.name); const existing = await fs.lstat(target).catch(() => null); if (existing) { - summary.skipped.push(entry.name); - continue; + if (existing.isSymbolicLink()) { + let linkedPath: string | null = null; + try { + linkedPath = await fs.readlink(target); + } catch (err) { + await fs.unlink(target); + try { + await fs.symlink(source, target); + summary.linked.push(entry.name); + continue; + } catch (linkErr) { + summary.failed.push({ + name: entry.name, + error: + err instanceof Error && linkErr instanceof Error + ? `${err.message}; then ${linkErr.message}` + : err instanceof Error + ? err.message + : `Failed to recover broken symlink: ${String(err)}`, + }); + continue; + } + } + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + const linkedTargetExists = await fs + .stat(resolvedLinkedPath) + .then(() => true) + .catch(() => false); + + if (!linkedTargetExists) { + await fs.unlink(target); + } else { + summary.skipped.push(entry.name); + continue; + } + } else { + summary.skipped.push(entry.name); + continue; + } } try { @@ -210,7 +248,7 @@ export function registerAgentCommands(program: Command): void { const installSummaries: SkillsInstallSummary[] = []; if (opts.installSkills !== false) { - const skillsDir = await resolvePaperclipSkillsDir(); + const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [path.resolve(process.cwd(), "skills")]); if (!skillsDir) { throw new Error( "Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.", @@ -258,7 +296,7 @@ export function registerAgentCommands(program: Command): void { if (installSummaries.length > 0) { for (const summary of installSummaries) { console.log( - `${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, + `${summary.tool}: linked=${summary.linked.length} removed=${summary.removed.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`, ); for (const failed of summary.failed) { console.log(` failed ${failed.name}: ${failed.error}`); diff --git a/cli/src/commands/client/plugin.ts b/cli/src/commands/client/plugin.ts new file mode 100644 index 00000000..9031d696 --- /dev/null +++ b/cli/src/commands/client/plugin.ts @@ -0,0 +1,374 @@ +import path from "node:path"; +import { Command } from "commander"; +import pc from "picocolors"; +import { + addCommonClientOptions, + handleCommandError, + printOutput, + resolveCommandContext, + type BaseClientOptions, +} from "./common.js"; + +// --------------------------------------------------------------------------- +// Types mirroring server-side shapes +// --------------------------------------------------------------------------- + +interface PluginRecord { + id: string; + pluginKey: string; + packageName: string; + version: string; + status: string; + displayName?: string; + lastError?: string | null; + installedAt: string; + updatedAt: string; +} + + +// --------------------------------------------------------------------------- +// Option types +// --------------------------------------------------------------------------- + +interface PluginListOptions extends BaseClientOptions { + status?: string; +} + +interface PluginInstallOptions extends BaseClientOptions { + local?: boolean; + version?: string; +} + +interface PluginUninstallOptions extends BaseClientOptions { + force?: boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Resolve a local path argument to an absolute path so the server can find the + * plugin on disk regardless of where the user ran the CLI. + */ +function resolvePackageArg(packageArg: string, isLocal: boolean): string { + if (!isLocal) return packageArg; + // Already absolute + if (path.isAbsolute(packageArg)) return packageArg; + // Expand leading ~ to home directory + if (packageArg.startsWith("~")) { + const home = process.env.HOME ?? process.env.USERPROFILE ?? ""; + return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, "")); + } + return path.resolve(process.cwd(), packageArg); +} + +function formatPlugin(p: PluginRecord): string { + const statusColor = + p.status === "ready" + ? pc.green(p.status) + : p.status === "error" + ? pc.red(p.status) + : p.status === "disabled" + ? pc.dim(p.status) + : pc.yellow(p.status); + + const parts = [ + `key=${pc.bold(p.pluginKey)}`, + `status=${statusColor}`, + `version=${p.version}`, + `id=${pc.dim(p.id)}`, + ]; + + if (p.lastError) { + parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`); + } + + return parts.join(" "); +} + +// --------------------------------------------------------------------------- +// Command registration +// --------------------------------------------------------------------------- + +export function registerPluginCommands(program: Command): void { + const plugin = program.command("plugin").description("Plugin lifecycle management"); + + // ------------------------------------------------------------------------- + // plugin list + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("list") + .description("List installed plugins") + .option("--status ", "Filter by status (ready, error, disabled, installed, upgrade_pending)") + .action(async (opts: PluginListOptions) => { + try { + const ctx = resolveCommandContext(opts); + const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : ""; + const plugins = await ctx.api.get(`/api/plugins${qs}`); + + if (ctx.json) { + printOutput(plugins, { json: true }); + return; + } + + const rows = plugins ?? []; + if (rows.length === 0) { + console.log(pc.dim("No plugins installed.")); + return; + } + + for (const p of rows) { + console.log(formatPlugin(p)); + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin install + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("install ") + .description( + "Install a plugin from a local path or npm package.\n" + + " Examples:\n" + + " paperclipai plugin install ./my-plugin # local path\n" + + " paperclipai plugin install @acme/plugin-linear # npm package\n" + + " paperclipai plugin install @acme/plugin-linear@1.2 # pinned version", + ) + .option("-l, --local", "Treat as a local filesystem path", false) + .option("--version ", "Specific npm version to install (npm packages only)") + .action(async (packageArg: string, opts: PluginInstallOptions) => { + try { + const ctx = resolveCommandContext(opts); + + // Auto-detect local paths: starts with . or / or ~ or is an absolute path + const isLocal = + opts.local || + packageArg.startsWith("./") || + packageArg.startsWith("../") || + packageArg.startsWith("/") || + packageArg.startsWith("~"); + + const resolvedPackage = resolvePackageArg(packageArg, isLocal); + + if (!ctx.json) { + console.log( + pc.dim( + isLocal + ? `Installing plugin from local path: ${resolvedPackage}` + : `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`, + ), + ); + } + + const installedPlugin = await ctx.api.post("/api/plugins/install", { + packageName: resolvedPackage, + version: opts.version, + isLocalPath: isLocal, + }); + + if (ctx.json) { + printOutput(installedPlugin, { json: true }); + return; + } + + if (!installedPlugin) { + console.log(pc.dim("Install returned no plugin record.")); + return; + } + + console.log( + pc.green( + `✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`, + ), + ); + + if (installedPlugin.lastError) { + console.log(pc.red(` Warning: ${installedPlugin.lastError}`)); + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin uninstall + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("uninstall ") + .description( + "Uninstall a plugin by its plugin key or database ID.\n" + + " Use --force to hard-purge all state and config.", + ) + .option("--force", "Purge all plugin state and config (hard delete)", false) + .action(async (pluginKey: string, opts: PluginUninstallOptions) => { + try { + const ctx = resolveCommandContext(opts); + const purge = opts.force === true; + const qs = purge ? "?purge=true" : ""; + + if (!ctx.json) { + console.log( + pc.dim( + purge + ? `Uninstalling and purging plugin: ${pluginKey}` + : `Uninstalling plugin: ${pluginKey}`, + ), + ); + } + + const result = await ctx.api.delete( + `/api/plugins/${encodeURIComponent(pluginKey)}${qs}`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`)); + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin enable + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("enable ") + .description("Enable a disabled or errored plugin") + .action(async (pluginKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post( + `/api/plugins/${encodeURIComponent(pluginKey)}/enable`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin disable + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("disable ") + .description("Disable a running plugin without uninstalling it") + .action(async (pluginKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.post( + `/api/plugins/${encodeURIComponent(pluginKey)}/disable`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`)); + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin inspect + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("inspect ") + .description("Show full details for an installed plugin") + .action(async (pluginKey: string, opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const result = await ctx.api.get( + `/api/plugins/${encodeURIComponent(pluginKey)}`, + ); + + if (ctx.json) { + printOutput(result, { json: true }); + return; + } + + if (!result) { + console.log(pc.red(`Plugin not found: ${pluginKey}`)); + process.exit(1); + } + + console.log(formatPlugin(result)); + if (result.lastError) { + console.log(`\n${pc.red("Last error:")}\n${result.lastError}`); + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + // ------------------------------------------------------------------------- + // plugin examples + // ------------------------------------------------------------------------- + addCommonClientOptions( + plugin + .command("examples") + .description("List bundled example plugins available for local install") + .action(async (opts: BaseClientOptions) => { + try { + const ctx = resolveCommandContext(opts); + const examples = await ctx.api.get< + Array<{ + packageName: string; + pluginKey: string; + displayName: string; + description: string; + localPath: string; + tag: string; + }> + >("/api/plugins/examples"); + + if (ctx.json) { + printOutput(examples, { json: true }); + return; + } + + const rows = examples ?? []; + if (rows.length === 0) { + console.log(pc.dim("No bundled examples available.")); + return; + } + + for (const ex of rows) { + console.log( + `${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` + + ` ${ex.description}\n` + + ` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`, + ); + } + } catch (err) { + handleCommandError(err); + } + }), + ); +} diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 4a0a3aeb..5249acc2 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -1,3 +1,4 @@ +import { randomInt } from "node:crypto"; import path from "node:path"; import type { PaperclipConfig } from "../config/schema.js"; import { expandHomePrefix } from "../config/home.js"; @@ -44,6 +45,11 @@ export type WorktreeLocalPaths = { storageDir: string; }; +export type WorktreeUiBranding = { + name: string; + color: string; +}; + export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { return (WORKTREE_SEED_MODES as readonly string[]).includes(value); } @@ -87,6 +93,51 @@ export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string) return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); } +function hslComponentToHex(n: number): string { + return Math.round(Math.max(0, Math.min(255, n))) + .toString(16) + .padStart(2, "0"); +} + +function hslToHex(hue: number, saturation: number, lightness: number): string { + const s = Math.max(0, Math.min(100, saturation)) / 100; + const l = Math.max(0, Math.min(100, lightness)) / 100; + const c = (1 - Math.abs((2 * l) - 1)) * s; + const h = ((hue % 360) + 360) % 360; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = l - (c / 2); + + let r = 0; + let g = 0; + let b = 0; + + if (h < 60) { + r = c; + g = x; + } else if (h < 120) { + r = x; + g = c; + } else if (h < 180) { + g = c; + b = x; + } else if (h < 240) { + g = x; + b = c; + } else if (h < 300) { + r = x; + b = c; + } else { + r = c; + b = x; + } + + return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; +} + +export function generateWorktreeColor(): string { + return hslToHex(randomInt(0, 360), 68, 56); +} + export function resolveWorktreeLocalPaths(opts: { cwd: string; homeDir?: string; @@ -196,13 +247,18 @@ export function buildWorktreeConfig(input: { }; } -export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record { +export function buildWorktreeEnvEntries( + paths: WorktreeLocalPaths, + branding?: WorktreeUiBranding, +): Record { return { PAPERCLIP_HOME: paths.homeDir, PAPERCLIP_INSTANCE_ID: paths.instanceId, PAPERCLIP_CONFIG: paths.configPath, PAPERCLIP_CONTEXT: paths.contextPath, PAPERCLIP_IN_WORKTREE: "true", + ...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}), + ...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}), }; } diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 2ef42abf..b77317fd 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -39,6 +39,7 @@ import { buildWorktreeEnvEntries, DEFAULT_WORKTREE_HOME, formatShellExports, + generateWorktreeColor, isWorktreeSeedMode, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, @@ -55,6 +56,7 @@ type WorktreeInitOptions = { fromConfig?: string; fromDataDir?: string; fromInstance?: string; + sourceConfigPathOverride?: string; serverPort?: number; dbPort?: number; seed?: boolean; @@ -62,7 +64,9 @@ type WorktreeInitOptions = { force?: boolean; }; -type WorktreeMakeOptions = WorktreeInitOptions; +type WorktreeMakeOptions = WorktreeInitOptions & { + startPoint?: string; +}; type WorktreeEnvOptions = { config?: string; @@ -81,6 +85,7 @@ type EmbeddedPostgresCtor = new (opts: { password: string; port: number; persistent: boolean; + initdbFlags?: string[]; onLog?: (message: unknown) => void; onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; @@ -117,6 +122,16 @@ function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function isCurrentSourceConfigPath(sourceConfigPath: string): boolean { + const currentConfigPath = process.env.PAPERCLIP_CONFIG; + if (!currentConfigPath || currentConfigPath.trim().length === 0) { + return false; + } + return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath); +} + +const WORKTREE_NAME_PREFIX = "paperclip-"; + function resolveWorktreeMakeName(name: string): string { const value = nonEmpty(name); if (!value) { @@ -127,7 +142,15 @@ function resolveWorktreeMakeName(name: string): string { "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", ); } - return value; + return value.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`; +} + +function resolveWorktreeHome(explicit?: string): string { + return explicit ?? process.env.PAPERCLIP_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME; +} + +function resolveWorktreeStartPoint(explicit?: string): string | undefined { + return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined; } export function resolveWorktreeMakeTargetPath(name: string): string { @@ -166,11 +189,13 @@ export function resolveGitWorktreeAddArgs(input: { branchName: string; targetPath: string; branchExists: boolean; + startPoint?: string; }): string[] { - if (input.branchExists) { + if (input.branchExists && !input.startPoint) { return ["worktree", "add", input.targetPath, input.branchName]; } - return ["worktree", "add", "-b", input.branchName, input.targetPath, "HEAD"]; + const commitish = input.startPoint ?? "HEAD"; + return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish]; } function readPidFilePort(postmasterPidFile: string): number | null { @@ -402,8 +427,12 @@ async function rebindSeededProjectWorkspaces(input: { } } -function resolveSourceConfigPath(opts: WorktreeInitOptions): string { +export function resolveSourceConfigPath(opts: WorktreeInitOptions): string { + if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride); if (opts.fromConfig) return path.resolve(opts.fromConfig); + if (!opts.fromDataDir && !opts.fromInstance) { + return resolveConfigPath(); + } const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default"); return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); @@ -436,9 +465,10 @@ export function copySeededSecretsKey(input: { mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); + const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath); const sourceInlineMasterKey = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? - nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY); + (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null); if (sourceInlineMasterKey) { writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { encoding: "utf8", @@ -454,7 +484,7 @@ export function copySeededSecretsKey(input: { const sourceKeyFileOverride = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ?? - nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE); + (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null); const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); @@ -501,6 +531,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P password: "paperclip", port, persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], onLog: () => {}, onError: () => {}, }); @@ -598,7 +629,7 @@ async function seedWorktreeDatabase(input: { async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const cwd = process.cwd(); - const name = resolveSuggestedWorktreeName( + const worktreeName = resolveSuggestedWorktreeName( cwd, opts.name ?? detectGitBranchName(cwd) ?? undefined, ); @@ -606,12 +637,16 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { if (!isWorktreeSeedMode(seedMode)) { throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); } - const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); + const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? worktreeName); const paths = resolveWorktreeLocalPaths({ cwd, - homeDir: opts.home ?? DEFAULT_WORKTREE_HOME, + homeDir: resolveWorktreeHome(opts.home), instanceId, }); + const branding = { + name: worktreeName, + color: generateWorktreeColor(), + }; const sourceConfigPath = resolveSourceConfigPath(opts); const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; @@ -638,7 +673,17 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { }); writeConfig(targetConfig, paths.configPath); - mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath); + const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath)); + const existingAgentJwtSecret = + nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ?? + nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); + mergePaperclipEnvEntries( + { + ...buildWorktreeEnvEntries(paths, branding), + ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), + }, + paths.envPath, + ); ensureAgentJwtSecret(paths.configPath); loadPaperclipEnvFile(paths.configPath); const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd); @@ -675,6 +720,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { p.log.message(pc.dim(`Repo env: ${paths.envPath}`)); p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); + p.log.message(pc.dim(`Worktree badge: ${branding.name} (${branding.color})`)); p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); if (copiedGitHooks?.copied) { p.log.message( @@ -708,17 +754,34 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); const name = resolveWorktreeMakeName(nameArg); + const startPoint = resolveWorktreeStartPoint(opts.startPoint); const sourceCwd = process.cwd(); + const sourceConfigPath = resolveSourceConfigPath(opts); const targetPath = resolveWorktreeMakeTargetPath(name); if (existsSync(targetPath)) { throw new Error(`Target path already exists: ${targetPath}`); } mkdirSync(path.dirname(targetPath), { recursive: true }); + if (startPoint) { + const [remote] = startPoint.split("/", 1); + try { + execFileSync("git", ["fetch", remote], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + throw new Error( + `Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`, + ); + } + } + const worktreeArgs = resolveGitWorktreeAddArgs({ branchName: name, targetPath, - branchExists: localBranchExists(sourceCwd, name), + branchExists: !startPoint && localBranchExists(sourceCwd, name), + startPoint, }); const spinner = p.spinner(); @@ -734,12 +797,26 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt throw new Error(extractExecSyncErrorMessage(error) ?? String(error)); } + const installSpinner = p.spinner(); + installSpinner.start("Installing dependencies..."); + try { + execFileSync("pnpm", ["install"], { + cwd: targetPath, + stdio: ["ignore", "pipe", "pipe"], + }); + installSpinner.stop("Installed dependencies."); + } catch (error) { + installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway).")); + p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); + } + const originalCwd = process.cwd(); try { process.chdir(targetPath); await runWorktreeInit({ ...opts, name, + sourceConfigPathOverride: sourceConfigPath, }); } catch (error) { throw error; @@ -748,6 +825,232 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt } } +type WorktreeCleanupOptions = { + instance?: string; + home?: string; + force?: boolean; +}; + +type GitWorktreeListEntry = { + worktree: string; + branch: string | null; + bare: boolean; + detached: boolean; +}; + +function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { + const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + const entries: GitWorktreeListEntry[] = []; + let current: Partial = {}; + for (const line of raw.split("\n")) { + if (line.startsWith("worktree ")) { + current = { worktree: line.slice("worktree ".length) }; + } else if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length); + } else if (line === "bare") { + current.bare = true; + } else if (line === "detached") { + current.detached = true; + } else if (line === "" && current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + bare: current.bare ?? false, + detached: current.detached ?? false, + }); + current = {}; + } + } + if (current.worktree) { + entries.push({ + worktree: current.worktree, + branch: current.branch ?? null, + bare: current.bare ?? false, + detached: current.detached ?? false, + }); + } + return entries; +} + +function branchHasUniqueCommits(cwd: string, branchName: string): boolean { + try { + const output = execFileSync( + "git", + ["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + return output.length > 0; + } catch { + return false; + } +} + +function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean { + try { + const output = execFileSync( + "git", + ["branch", "-r", "--list", `*/${branchName}`], + { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + return output.length > 0; + } catch { + return false; + } +} + +function worktreePathHasUncommittedChanges(worktreePath: string): boolean { + try { + const output = execFileSync( + "git", + ["status", "--porcelain"], + { cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + return output.length > 0; + } catch { + return false; + } +} + +export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise { + printPaperclipCliBanner(); + p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup "))); + + const name = resolveWorktreeMakeName(nameArg); + const sourceCwd = process.cwd(); + const targetPath = resolveWorktreeMakeTargetPath(name); + const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); + const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home))); + const instanceRoot = path.resolve(homeDir, "instances", instanceId); + + // ── 1. Assess current state ────────────────────────────────────────── + + const hasBranch = localBranchExists(sourceCwd, name); + const hasTargetDir = existsSync(targetPath); + const hasInstanceData = existsSync(instanceRoot); + + const worktrees = parseGitWorktreeList(sourceCwd); + const linkedWorktree = worktrees.find( + (wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath), + ); + + if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) { + p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found."); + p.outro(pc.green("Already clean.")); + return; + } + + // ── 2. Safety checks ──────────────────────────────────────────────── + + const problems: string[] = []; + + if (hasBranch && branchHasUniqueCommits(sourceCwd, name)) { + const onRemote = branchExistsOnAnyRemote(sourceCwd, name); + if (onRemote) { + p.log.info( + `Branch "${name}" has unique local commits, but the branch also exists on a remote — safe to delete locally.`, + ); + } else { + problems.push( + `Branch "${name}" has commits not found on any other branch or remote. ` + + `Deleting it will lose work. Push it first, or use --force.`, + ); + } + } + + if (hasTargetDir && worktreePathHasUncommittedChanges(targetPath)) { + problems.push( + `Worktree directory ${targetPath} has uncommitted changes. Commit or stash first, or use --force.`, + ); + } + + if (problems.length > 0 && !opts.force) { + for (const problem of problems) { + p.log.error(problem); + } + throw new Error("Safety checks failed. Resolve the issues above or re-run with --force."); + } + if (problems.length > 0 && opts.force) { + for (const problem of problems) { + p.log.warning(`Overridden by --force: ${problem}`); + } + } + + // ── 3. Clean up (idempotent steps) ────────────────────────────────── + + // 3a. Remove the git worktree registration + if (linkedWorktree) { + const worktreeDirExists = existsSync(linkedWorktree.worktree); + const spinner = p.spinner(); + if (worktreeDirExists) { + spinner.start(`Removing git worktree at ${linkedWorktree.worktree}...`); + try { + const removeArgs = ["worktree", "remove", linkedWorktree.worktree]; + if (opts.force) removeArgs.push("--force"); + execFileSync("git", removeArgs, { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`); + } catch (error) { + spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`)); + p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); + } + } else { + spinner.start("Pruning stale worktree entry..."); + execFileSync("git", ["worktree", "prune"], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop("Pruned stale worktree entry."); + } + } else { + // Even without a linked worktree, prune to clean up any orphaned entries + execFileSync("git", ["worktree", "prune"], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + } + + // 3b. Remove the worktree directory if it still exists (e.g. partial creation) + if (existsSync(targetPath)) { + const spinner = p.spinner(); + spinner.start(`Removing worktree directory ${targetPath}...`); + rmSync(targetPath, { recursive: true, force: true }); + spinner.stop(`Removed worktree directory ${targetPath}.`); + } + + // 3c. Delete the local branch (now safe — worktree is gone) + if (localBranchExists(sourceCwd, name)) { + const spinner = p.spinner(); + spinner.start(`Deleting local branch "${name}"...`); + try { + const deleteFlag = opts.force ? "-D" : "-d"; + execFileSync("git", ["branch", deleteFlag, name], { + cwd: sourceCwd, + stdio: ["ignore", "pipe", "pipe"], + }); + spinner.stop(`Deleted local branch "${name}".`); + } catch (error) { + spinner.stop(pc.yellow(`Could not delete branch "${name}".`)); + p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); + } + } + + // 3d. Remove instance data + if (existsSync(instanceRoot)) { + const spinner = p.spinner(); + spinner.start(`Removing instance data at ${instanceRoot}...`); + rmSync(instanceRoot, { recursive: true, force: true }); + spinner.stop(`Removed instance data at ${instanceRoot}.`); + } + + p.outro(pc.green("Cleanup complete.")); +} + export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise { const configPath = resolveConfigPath(opts.config); const envPath = resolvePaperclipEnvFile(configPath); @@ -774,9 +1077,10 @@ export function registerWorktreeCommands(program: Command): void { program .command("worktree:make") .description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it") - .argument("", "Worktree directory and branch name (created at ~/NAME)") + .argument("", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)") + .option("--start-point ", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)") .option("--instance ", "Explicit isolated instance id") - .option("--home ", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`) + .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) .option("--from-config ", "Source config.json to seed from") .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config", "default") @@ -792,7 +1096,7 @@ export function registerWorktreeCommands(program: Command): void { .description("Create repo-local config/env and an isolated instance for this worktree") .option("--name ", "Display name used to derive the instance id") .option("--instance ", "Explicit isolated instance id") - .option("--home ", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`) + .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) .option("--from-config ", "Source config.json to seed from") .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config", "default") @@ -809,4 +1113,13 @@ export function registerWorktreeCommands(program: Command): void { .option("-c, --config ", "Path to config file") .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); + + program + .command("worktree:cleanup") + .description("Safely remove a worktree, its branch, and its isolated instance data") + .argument("", "Worktree name — auto-prefixed with paperclip- if needed") + .option("--instance ", "Explicit instance id (if different from the worktree name)") + .option("--home ", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) + .option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false) + .action(worktreeCleanupCommand); } diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 4bc8f16e..a7266ea2 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -22,11 +22,18 @@ function parseEnvFile(contents: string) { } } +function formatEnvValue(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) { + return value; + } + return JSON.stringify(value); +} + function renderEnvFile(entries: Record) { const lines = [ "# Paperclip environment variables", "# Generated by Paperclip CLI commands", - ...Object.entries(entries).map(([key, value]) => `${key}=${value}`), + ...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`), "", ]; return lines.join("\n"); diff --git a/cli/src/index.ts b/cli/src/index.ts index 19ef69f9..628cd7e7 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -18,6 +18,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; import { loadPaperclipEnvFile } from "./config/env.js"; import { registerWorktreeCommands } from "./commands/worktree.js"; +import { registerPluginCommands } from "./commands/client/plugin.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -136,6 +137,7 @@ registerApprovalCommands(program); registerActivityCommands(program); registerDashboardCommands(program); registerWorktreeCommands(program); +registerPluginCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index 00611560..e5c26180 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -162,4 +162,3 @@ export async function promptServer(opts?: { auth, }; } - diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index b1adb579..e3668516 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -142,7 +142,7 @@ This command: - creates an isolated instance under `~/.paperclip-worktrees/instances//` - when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir - picks a free app port and embedded PostgreSQL port -- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot +- by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot Seed modes: @@ -152,7 +152,13 @@ Seed modes: After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. -That repo-local env also sets `PAPERCLIP_IN_WORKTREE=true`, which the server can use for worktree-specific UI behavior such as an alternate favicon. +That repo-local env also sets: + +- `PAPERCLIP_IN_WORKTREE=true` +- `PAPERCLIP_WORKTREE_NAME=` +- `PAPERCLIP_WORKTREE_COLOR=` + +The server/UI use those values for worktree-specific branding such as the top banner and dynamically colored favicon. Print shell exports explicitly when needed: @@ -162,17 +168,73 @@ paperclipai worktree env eval "$(paperclipai worktree env)" ``` -Useful options: +### Worktree CLI Reference + +**`pnpm paperclipai worktree init [options]`** — Create repo-local config/env and an isolated instance for the current worktree. + +| Option | Description | +|---|---| +| `--name ` | Display name used to derive the instance id | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source PAPERCLIP_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | + +Examples: ```sh paperclipai worktree init --no-seed -paperclipai worktree init --seed-mode minimal paperclipai worktree init --seed-mode full paperclipai worktree init --from-instance default paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --force ``` +**`pnpm paperclipai worktree:make [options]`** — Create `~/NAME` as a git worktree, then initialize an isolated Paperclip instance inside it. This combines `git worktree add` with `worktree init` in a single step. + +| Option | Description | +|---|---| +| `--start-point ` | Remote ref to base the new branch on (e.g. `origin/main`) | +| `--instance ` | Explicit isolated instance id | +| `--home ` | Home root for worktree instances (default: `~/.paperclip-worktrees`) | +| `--from-config ` | Source config.json to seed from | +| `--from-data-dir ` | Source PAPERCLIP_HOME used when deriving the source config | +| `--from-instance ` | Source instance id (default: `default`) | +| `--server-port ` | Preferred server port | +| `--db-port ` | Preferred embedded Postgres port | +| `--seed-mode ` | Seed profile: `minimal` or `full` (default: `minimal`) | +| `--no-seed` | Skip database seeding from the source instance | +| `--force` | Replace existing repo-local config and isolated instance data | + +Examples: + +```sh +pnpm paperclipai worktree:make paperclip-pr-432 +pnpm paperclipai worktree:make my-feature --start-point origin/main +pnpm paperclipai worktree:make experiment --no-seed +``` + +**`pnpm paperclipai worktree env [options]`** — Print shell exports for the current worktree-local Paperclip instance. + +| Option | Description | +|---|---| +| `-c, --config ` | Path to config file | +| `--json` | Print JSON instead of shell exports | + +Examples: + +```sh +pnpm paperclipai worktree env +pnpm paperclipai worktree env --json +eval "$(pnpm paperclipai worktree env)" +``` + For project execution worktrees, Paperclip can also run a project-defined provision command after it creates or reuses an isolated git worktree. Configure this on the project's execution workspace policy (`workspaceStrategy.provisionCommand`). The command runs inside the derived worktree and receives `PAPERCLIP_WORKSPACE_*`, `PAPERCLIP_PROJECT_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_ISSUE_*` environment variables so each repo can bootstrap itself however it wants. ## Quick Health Checks diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 741df662..f835889c 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -94,3 +94,53 @@ Canonical mode design and command expectations live in `doc/DEPLOYMENT-MODES.md` ## Further Detail See [SPEC.md](./SPEC.md) for the full technical specification and [TASKS.md](./TASKS.md) for the task management data model. + +--- + +Paperclip’s core identity is a **control plane for autonomous AI companies**, centered on **companies, org charts, goals, issues/comments, heartbeats, budgets, approvals, and board governance**. The public docs are also explicit about the current boundaries: **tasks/comments are the built-in communication model**, Paperclip is **not a chatbot**, and it is **not a code review tool**. The roadmap already points toward **easier onboarding, cloud agents, easier agent configuration, plugins, better docs, and ClipMart/ClipHub-style reusable companies/templates**. + +## What Paperclip should do vs. not do + +**Do** + +- Stay **board-level and company-level**. Users should manage goals, orgs, budgets, approvals, and outputs. +- Make the first five minutes feel magical: install, answer a few questions, see a CEO do something real. +- Keep work anchored to **issues/comments/projects/goals**, even if the surface feels conversational. +- Treat **agency / internal team / startup** as the same underlying abstraction with different templates and labels. +- Make outputs first-class: files, docs, reports, previews, links, screenshots. +- Provide **hooks into engineering workflows**: worktrees, preview servers, PR links, external review tools. +- Use **plugins** for edge cases like rich chat, knowledge bases, doc editors, custom tracing. + +**Do not** + +- Do not make the core product a general chat app. The current product definition is explicitly task/comment-centric and “not a chatbot,” and that boundary is valuable. +- Do not build a complete Jira/GitHub replacement. The repo/docs already position Paperclip as organization orchestration, not focused on pull-request review. +- Do not build enterprise-grade RBAC first. The current V1 spec still treats multi-board governance and fine-grained human permissions as out of scope, so the first multi-user version should be coarse and company-scoped. +- Do not lead with raw bash logs and transcripts. Default view should be human-readable intent/progress, with raw detail beneath. +- Do not force users to understand provider/API-key plumbing unless absolutely necessary. There are active onboarding/auth issues already; friction here is clearly real. + +## Specific design goals + +1. **Time-to-first-success under 5 minutes** + A fresh user should go from install to “my CEO completed a first task” in one sitting. + +2. **Board-level abstraction always wins** + The default UI should answer: what is the company doing, who is doing it, why does it matter, what did it cost, and what needs my approval. + +3. **Conversation stays attached to work objects** + “Chat with CEO” should still resolve to strategy threads, decisions, tasks, or approvals. + +4. **Progressive disclosure** + Top layer: human-readable summary. Middle layer: checklist/steps/artifacts. Bottom layer: raw logs/tool calls/transcript. + +5. **Output-first** + Work is not done until the user can see the result: file, document, preview link, screenshot, plan, or PR. + +6. **Local-first, cloud-ready** + The mental model should not change between local solo use and shared/private or public/cloud deployment. + +7. **Safe autonomy** + Auto mode is allowed; hidden token burn is not. + +8. **Thin core, rich edges** + Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane. diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 5f951d69..69d17366 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -58,7 +58,7 @@ From the release worktree: ```bash VERSION=X.Y.Z -claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." +claude --print --output-format stream-json --verbose --dangerously-skip-permissions --model claude-opus-4-6 "Use the release-changelog skill to draft or update releases/v${VERSION}.md for Paperclip. Read doc/RELEASING.md and .agents/skills/release-changelog/SKILL.md, then generate the stable changelog for v${VERSION} from commits since the last stable tag. Do not create a canary changelog." ``` ### 3. Verify and publish a canary @@ -418,5 +418,5 @@ If the release already exists, the script updates it. ## Related Docs - [doc/PUBLISHING.md](PUBLISHING.md) — low-level npm build and packaging internals -- [skills/release/SKILL.md](../skills/release/SKILL.md) — agent release coordination workflow -- [skills/release-changelog/SKILL.md](../skills/release-changelog/SKILL.md) — stable changelog drafting workflow +- [.agents/skills/release/SKILL.md](../.agents/skills/release/SKILL.md) — maintainer release coordination workflow +- [.agents/skills/release-changelog/SKILL.md](../.agents/skills/release-changelog/SKILL.md) — stable changelog drafting workflow diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 430dcabb..7a4b1cbc 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -37,7 +37,7 @@ These decisions close open questions from `SPEC.md` for V1. | Visibility | Full visibility to board and all agents in same company | | Communication | Tasks + comments only (no separate chat system) | | Task ownership | Single assignee; atomic checkout required for `in_progress` transition | -| Recovery | No automatic reassignment; stale work is surfaced, not silently fixed | +| Recovery | No automatic reassignment; work recovery stays manual/explicit | | Agent adapters | Built-in `process` and `http` adapters | | Auth | Mode-dependent human auth (`local_trusted` implicit board in current code; authenticated mode uses sessions), API keys for agents | | Budget period | Monthly UTC calendar window | @@ -106,7 +106,6 @@ A lightweight scheduler/worker in the server process handles: - heartbeat trigger checks - stuck run detection - budget threshold checks -- stale task reporting generation Separate queue infrastructure is not required for V1. @@ -331,6 +330,34 @@ Operational policy: - `asset_id` uuid fk not null - `issue_comment_id` uuid fk null +## 7.15 `documents` + `document_revisions` + `issue_documents` + +- `documents` stores editable text-first documents: + - `id` uuid pk + - `company_id` uuid fk not null + - `title` text null + - `format` text not null (`markdown`) + - `latest_body` text not null + - `latest_revision_id` uuid null + - `latest_revision_number` int not null + - `created_by_agent_id` uuid fk null + - `created_by_user_id` uuid/text fk null + - `updated_by_agent_id` uuid fk null + - `updated_by_user_id` uuid/text fk null +- `document_revisions` stores append-only history: + - `id` uuid pk + - `company_id` uuid fk not null + - `document_id` uuid fk not null + - `revision_number` int not null + - `body` text not null + - `change_summary` text null +- `issue_documents` links documents to issues with a stable workflow key: + - `id` uuid pk + - `company_id` uuid fk not null + - `issue_id` uuid fk not null + - `document_id` uuid fk not null + - `key` text not null (`plan`, `design`, `notes`, etc.) + ## 8. State Machines ## 8.1 Agent Status @@ -442,6 +469,11 @@ All endpoints are under `/api` and return JSON. - `POST /companies/:companyId/issues` - `GET /issues/:issueId` - `PATCH /issues/:issueId` +- `GET /issues/:issueId/documents` +- `GET /issues/:issueId/documents/:key` +- `PUT /issues/:issueId/documents/:key` +- `GET /issues/:issueId/documents/:key/revisions` +- `DELETE /issues/:issueId/documents/:key` - `POST /issues/:issueId/checkout` - `POST /issues/:issueId/release` - `POST /issues/:issueId/comments` @@ -502,7 +534,6 @@ Dashboard payload must include: - open/in-progress/blocked/done issue counts - month-to-date spend and budget utilization - pending approvals count -- stale task count ## 10.9 Error Semantics @@ -681,7 +712,6 @@ Required UX behaviors: - global company selector - quick actions: pause/resume agent, create task, approve/reject request - conflict toasts on atomic checkout failure -- clear stale-task indicators - no silent background failures; every failed run visible in UI ## 15. Operational Requirements @@ -780,7 +810,6 @@ A release candidate is blocked unless these pass: - add company selector and org chart view - add approvals and cost pages -- add operational dashboard and stale-task surfacing ## Milestone 6: Hardening and Release diff --git a/doc/plans/module-system.md b/doc/plans/2026-02-16-module-system.md similarity index 100% rename from doc/plans/module-system.md rename to doc/plans/2026-02-16-module-system.md diff --git a/doc/plans/agent-authentication-implementation.md b/doc/plans/2026-02-18-agent-authentication-implementation.md similarity index 100% rename from doc/plans/agent-authentication-implementation.md rename to doc/plans/2026-02-18-agent-authentication-implementation.md diff --git a/doc/plans/agent-authentication.md b/doc/plans/2026-02-18-agent-authentication.md similarity index 100% rename from doc/plans/agent-authentication.md rename to doc/plans/2026-02-18-agent-authentication.md diff --git a/doc/plans/agent-mgmt-followup-plan.md b/doc/plans/2026-02-19-agent-mgmt-followup-plan.md similarity index 100% rename from doc/plans/agent-mgmt-followup-plan.md rename to doc/plans/2026-02-19-agent-mgmt-followup-plan.md diff --git a/doc/plans/ceo-agent-creation-and-hiring.md b/doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md similarity index 100% rename from doc/plans/ceo-agent-creation-and-hiring.md rename to doc/plans/2026-02-19-ceo-agent-creation-and-hiring.md diff --git a/doc/plans/issue-run-orchestration-plan.md b/doc/plans/2026-02-20-issue-run-orchestration-plan.md similarity index 100% rename from doc/plans/issue-run-orchestration-plan.md rename to doc/plans/2026-02-20-issue-run-orchestration-plan.md diff --git a/doc/plans/storage-system-implementation.md b/doc/plans/2026-02-20-storage-system-implementation.md similarity index 100% rename from doc/plans/storage-system-implementation.md rename to doc/plans/2026-02-20-storage-system-implementation.md diff --git a/doc/plan/humans-and-permissions-implementation.md b/doc/plans/2026-02-21-humans-and-permissions-implementation.md similarity index 100% rename from doc/plan/humans-and-permissions-implementation.md rename to doc/plans/2026-02-21-humans-and-permissions-implementation.md diff --git a/doc/plan/humans-and-permissions.md b/doc/plans/2026-02-21-humans-and-permissions.md similarity index 100% rename from doc/plan/humans-and-permissions.md rename to doc/plans/2026-02-21-humans-and-permissions.md diff --git a/doc/plans/cursor-cloud-adapter.md b/doc/plans/2026-02-23-cursor-cloud-adapter.md similarity index 100% rename from doc/plans/cursor-cloud-adapter.md rename to doc/plans/2026-02-23-cursor-cloud-adapter.md diff --git a/doc/plans/deployment-auth-mode-consolidation.md b/doc/plans/2026-02-23-deployment-auth-mode-consolidation.md similarity index 100% rename from doc/plans/deployment-auth-mode-consolidation.md rename to doc/plans/2026-02-23-deployment-auth-mode-consolidation.md diff --git a/doc/plans/workspace-strategy-and-git-worktrees.md b/doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md similarity index 100% rename from doc/plans/workspace-strategy-and-git-worktrees.md rename to doc/plans/2026-03-10-workspace-strategy-and-git-worktrees.md diff --git a/doc/plans/2026-03-11-agent-chat-ui-and-issue-backed-conversations.md b/doc/plans/2026-03-11-agent-chat-ui-and-issue-backed-conversations.md new file mode 100644 index 00000000..7364b6d0 --- /dev/null +++ b/doc/plans/2026-03-11-agent-chat-ui-and-issue-backed-conversations.md @@ -0,0 +1,329 @@ +# Agent Chat UI and Issue-Backed Conversations + +## Context + +`PAP-475` asks two related questions: + +1. What UI kit should Paperclip use if we add a chat surface with an agent? +2. How should chat fit the product without breaking the current issue-centric model? + +This is not only a component-library decision. In Paperclip today: + +- V1 explicitly says communication is `tasks + comments only`, with no separate chat system. +- Issues already carry assignment, audit trail, billing code, project linkage, goal linkage, and active run linkage. +- Live run streaming already exists on issue detail pages. +- Agent sessions already persist by `taskKey`, and today `taskKey` falls back to `issueId`. +- The OpenClaw gateway adapter already supports an issue-scoped session key strategy. + +That means the cheapest useful path is not "add a second messaging product inside Paperclip." It is "add a better conversational UI on top of issue and run primitives we already have." + +## Current Constraints From the Codebase + +### Durable work object + +The durable object in Paperclip is the issue, not a chat thread. + +- `IssueDetail` already combines comments, linked runs, live runs, and activity into one timeline. +- `CommentThread` already renders markdown comments and supports reply/reassignment flows. +- `LiveRunWidget` already renders streaming assistant/tool/system output for active runs. + +### Session behavior + +Session continuity is already task-shaped. + +- `heartbeat.ts` derives `taskKey` from `taskKey`, then `taskId`, then `issueId`. +- `agent_task_sessions` stores session state per company + agent + adapter + task key. +- OpenClaw gateway supports `sessionKeyStrategy=issue|fixed|run`, and `issue` already matches the Paperclip mental model well. + +That means "chat with the CEO about this issue" naturally maps to one durable session per issue today without inventing a second session system. + +### Billing behavior + +Billing is already issue-aware. + +- `cost_events` can attach to `issueId`, `projectId`, `goalId`, and `billingCode`. +- heartbeat context already propagates issue linkage into runs and cost rollups. + +If chat leaves the issue model, Paperclip would need a second billing story. That is avoidable. + +## UI Kit Recommendation + +## Recommendation: `assistant-ui` + +Use `assistant-ui` as the chat presentation layer. + +Why it fits Paperclip: + +- It is a real chat UI kit, not just a hook. +- It is composable and aligned with shadcn-style primitives, which matches the current UI stack well. +- It explicitly supports custom backends, which matters because Paperclip talks to agents through issue comments, heartbeats, and run streams rather than direct provider calls. +- It gives us polished chat affordances quickly: message list, composer, streaming text, attachments, thread affordances, and markdown-oriented rendering. + +Why not make "the Vercel one" the primary choice: + +- Vercel AI SDK is stronger today than the older "just `useChat` over `/api/chat`" framing. Its transport layer is flexible and can support custom protocols. +- But AI SDK is still better understood here as a transport/runtime protocol layer than as the best end-user chat surface for Paperclip. +- Paperclip does not need Vercel to own message state, persistence, or the backend contract. Paperclip already has its own issue, run, and session model. + +So the clean split is: + +- `assistant-ui` for UI primitives +- Paperclip-owned runtime/store for state, persistence, and transport +- optional AI SDK usage later only if we want its stream protocol or client transport abstraction + +## Product Options + +### Option A: Separate chat object + +Create a new top-level chat/thread model unrelated to issues. + +Pros: + +- clean mental model if users want freeform conversation +- easy to hide from issue boards + +Cons: + +- breaks the current V1 product decision that communication is issue-centric +- needs new persistence, billing, session, permissions, activity, and wakeup rules +- creates a second "why does this exist?" object beside issues +- makes "pick up an old chat" a separate retrieval problem + +Verdict: not recommended for V1. + +### Option B: Every chat is an issue + +Treat chat as a UI mode over an issue. The issue remains the durable record. + +Pros: + +- matches current product spec +- billing, runs, comments, approvals, and activity already work +- sessions already resume on issue identity +- works with all adapters, including OpenClaw, without new agent auth or a second API surface + +Cons: + +- some chats are not really "tasks" in a board sense +- onboarding and review conversations may clutter normal issue lists + +Verdict: best V1 foundation. + +### Option C: Hybrid with hidden conversation issues + +Back every conversation with an issue, but allow a conversation-flavored issue mode that is hidden from default execution boards unless promoted. + +Pros: + +- preserves the issue-centric backend +- gives onboarding/review chat a cleaner UX +- preserves billing and session continuity + +Cons: + +- requires extra UI rules and possibly a small schema or filtering addition +- can become a disguised second system if not kept narrow + +Verdict: likely the right product shape after a basic issue-backed MVP. + +## Recommended Product Model + +### Phase 1 product decision + +For the first implementation, chat should be issue-backed. + +More specifically: + +- the board opens a chat surface for an issue +- sending a message is a comment mutation on that issue +- the assigned agent is woken through the existing issue-comment flow +- streaming output comes from the existing live run stream for that issue +- durable assistant output remains comments and run history, not an extra transcript store + +This keeps Paperclip honest about what it is: + +- the control plane stays issue-centric +- chat is a better way to interact with issue work, not a new collaboration product + +### Onboarding and CEO conversations + +For onboarding, weekly reviews, and "chat with the CEO", use a conversation issue rather than a global chat tab. + +Suggested shape: + +- create a board-initiated issue assigned to the CEO +- mark it as conversation-flavored in UI treatment +- optionally hide it from normal issue boards by default later +- keep all cost/run/session linkage on that issue + +This solves several concerns at once: + +- no separate API key or direct provider wiring is needed +- the same CEO adapter is used +- old conversations are recovered through normal issue history +- the CEO can still create or update real child issues from the conversation + +## Session Model + +### V1 + +Use one durable conversation session per issue. + +That already matches current behavior: + +- adapter task sessions persist against `taskKey` +- `taskKey` already falls back to `issueId` +- OpenClaw already supports an issue-scoped session key + +This means "resume the CEO conversation later" works by reopening the same issue and waking the same agent on the same issue. + +### What not to add yet + +Do not add multi-thread-per-issue chat in the first pass. + +If Paperclip later needs several parallel threads on one issue, then add an explicit conversation identity and derive: + +- `taskKey = issue::conversation:` +- OpenClaw `sessionKey = paperclip:conversation:` + +Until that requirement becomes real, one issue == one durable conversation is the simpler and better rule. + +## Billing Model + +Chat should not invent a separate billing pipeline. + +All chat cost should continue to roll up through the issue: + +- `cost_events.issueId` +- project and goal rollups through existing relationships +- issue `billingCode` when present + +If a conversation is important enough to exist, it is important enough to have a durable issue-backed audit and cost trail. + +This is another reason ephemeral freeform chat should not be the default. + +## UI Architecture + +### Recommended stack + +1. Keep Paperclip as the source of truth for message history and run state. +2. Add `assistant-ui` as the rendering/composer layer. +3. Build a Paperclip runtime adapter that maps: + - issue comments -> user/assistant messages + - live run deltas -> streaming assistant messages + - issue attachments -> chat attachments +4. Keep current markdown rendering and code-block support where possible. + +### Interaction flow + +1. Board opens issue detail in "Chat" mode. +2. Existing comment history is mapped into chat messages. +3. When the board sends a message: + - `POST /api/issues/{id}/comments` + - optionally interrupt the active run if the UX wants "send and replace current response" +4. Existing issue comment wakeup logic wakes the assignee. +5. Existing `/issues/{id}/live-runs` and `/issues/{id}/active-run` data feeds drive streaming. +6. When the run completes, durable state remains in comments/runs/activity as it does now. + +### Why this fits the current code + +Paperclip already has most of the backend pieces: + +- issue comments +- run timeline +- run log and event streaming +- markdown rendering +- attachment support +- assignee wakeups on comments + +The missing piece is mostly the presentation and the mapping layer, not a new backend domain. + +## Agent Scope + +Do not launch this as "chat with every agent." + +Start narrower: + +- onboarding chat with CEO +- workflow/review chat with CEO +- maybe selected exec roles later + +Reasons: + +- it keeps the feature from becoming a second inbox/chat product +- it limits permission and UX questions early +- it matches the stated product demand + +If direct chat with other agents becomes useful later, the same issue-backed pattern can expand cleanly. + +## Recommended Delivery Phases + +### Phase 1: Chat UI on existing issues + +- add a chat presentation mode to issue detail +- use `assistant-ui` +- map comments + live runs into the chat surface +- no schema change +- no new API surface + +This is the highest-leverage step because it tests whether the UX is actually useful before product model expansion. + +### Phase 2: Conversation-flavored issues for CEO chat + +- add a lightweight conversation classification +- support creation of CEO conversation issues from onboarding and workflow entry points +- optionally hide these from normal backlog/board views by default + +The smallest implementation could be a label or issue metadata flag. If it becomes important enough, then promote it to a first-class issue subtype later. + +### Phase 3: Promotion and thread splitting only if needed + +Only if we later see a real need: + +- allow promoting a conversation to a formal task issue +- allow several threads per issue with explicit conversation identity + +This should be demand-driven, not designed up front. + +## Clear Recommendation + +If the question is "what should we use?", the answer is: + +- use `assistant-ui` for the chat UI +- do not treat raw Vercel AI SDK UI hooks as the main product answer +- keep chat issue-backed in V1 +- use the current issue comment + run + session + billing model rather than inventing a parallel chat subsystem + +If the question is "how should we think about chat in Paperclip?", the answer is: + +- chat is a mode of interacting with issue-backed agent work +- not a separate product silo +- not an excuse to stop tracing work, cost, and session history back to the issue + +## Implementation Notes + +### Immediate implementation target + +The most defensible first build is: + +- add a chat tab or chat-focused layout on issue detail +- back it with the currently assigned agent on that issue +- use `assistant-ui` primitives over existing comments and live run events + +### Defer these until proven necessary + +- standalone global chat objects +- multi-thread chat inside one issue +- chat with every agent in the org +- a second persistence layer for message history +- separate cost tracking for chats + +## References + +- V1 communication model: `doc/SPEC-implementation.md` +- Current issue/comment/run UI: `ui/src/pages/IssueDetail.tsx`, `ui/src/components/CommentThread.tsx`, `ui/src/components/LiveRunWidget.tsx` +- Session persistence and task key derivation: `server/src/services/heartbeat.ts`, `packages/db/src/schema/agent_task_sessions.ts` +- OpenClaw session routing: `packages/adapters/openclaw-gateway/README.md` +- assistant-ui docs: +- assistant-ui repo: +- AI SDK transport docs: diff --git a/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md new file mode 100644 index 00000000..7053e97f --- /dev/null +++ b/doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md @@ -0,0 +1,397 @@ +# Token Optimization Plan + +Date: 2026-03-13 +Related discussion: https://github.com/paperclipai/paperclip/discussions/449 + +## Goal + +Reduce token consumption materially without reducing agent capability, control-plane visibility, or task completion quality. + +This plan is based on: + +- the current V1 control-plane design +- the current adapter and heartbeat implementation +- the linked user discussion +- local runtime data from the default Paperclip instance on 2026-03-13 + +## Executive Summary + +The discussion is directionally right about two things: + +1. We should preserve session and prompt-cache locality more aggressively. +2. We should separate stable startup instructions from per-heartbeat dynamic context. + +But that is not enough on its own. + +After reviewing the code and local run data, the token problem appears to have four distinct causes: + +1. **Measurement inflation on sessioned adapters.** Some token counters, especially for `codex_local`, appear to be recorded as cumulative session totals instead of per-heartbeat deltas. +2. **Avoidable session resets.** Task sessions are intentionally reset on timer wakes and manual wakes, which destroys cache locality for common heartbeat paths. +3. **Repeated context reacquisition.** The `paperclip` skill tells agents to re-fetch assignments, issue details, ancestors, and full comment threads on every heartbeat. The API does not currently offer efficient delta-oriented alternatives. +4. **Large static instruction surfaces.** Agent instruction files and globally injected skills are reintroduced at startup even when most of that content is unchanged and not needed for the current task. + +The correct approach is: + +1. fix telemetry so we can trust the numbers +2. preserve reuse where it is safe +3. make context retrieval incremental +4. add session compaction/rotation so long-lived sessions do not become progressively more expensive + +## Validated Findings + +### 1. Token telemetry is at least partly overstated today + +Observed from the local default instance: + +- `heartbeat_runs`: 11,360 runs between 2026-02-18 and 2026-03-13 +- summed `usage_json.inputTokens`: `2,272,142,368,952` +- summed `usage_json.cachedInputTokens`: `2,217,501,559,420` + +Those totals are not credible as true per-heartbeat usage for the observed prompt sizes. + +Supporting evidence: + +- `adapter.invoke.payload.prompt` averages were small: + - `codex_local`: ~193 chars average, 6,067 chars max + - `claude_local`: ~160 chars average, 1,160 chars max +- despite that, many `codex_local` runs report millions of input tokens +- one reused Codex session in local data spans 3,607 runs and recorded `inputTokens` growing up to `1,155,283,166` + +Interpretation: + +- for sessioned adapters, especially Codex, we are likely storing usage reported by the runtime as a **session total**, not a **per-run delta** +- this makes trend reporting, optimization work, and customer trust worse + +This does **not** mean there is no real token problem. It means we need a trustworthy baseline before we can judge optimization impact. + +### 2. Timer wakes currently throw away reusable task sessions + +In `server/src/services/heartbeat.ts`, `shouldResetTaskSessionForWake(...)` returns `true` for: + +- `wakeReason === "issue_assigned"` +- `wakeSource === "timer"` +- manual on-demand wakes + +That means many normal heartbeats skip saved task-session resume even when the workspace is stable. + +Local data supports the impact: + +- `timer/system` runs: 6,587 total +- only 976 had a previous session +- only 963 ended with the same session + +So timer wakes are the largest heartbeat path and are mostly not resuming prior task state. + +### 3. We repeatedly ask agents to reload the same task context + +The `paperclip` skill currently tells agents to do this on essentially every heartbeat: + +- fetch assignments +- fetch issue details +- fetch ancestor chain +- fetch full issue comments + +Current API shape reinforces that pattern: + +- `GET /api/issues/:id/comments` returns the full thread +- there is no `since`, cursor, digest, or summary endpoint for heartbeat consumption +- `GET /api/issues/:id` returns full enriched issue context, not a minimal delta payload + +This is safe but expensive. It forces the model to repeatedly consume unchanged information. + +### 4. Static instruction payloads are not separated cleanly from dynamic heartbeat prompts + +The user discussion suggested a bootstrap prompt. That is the right direction. + +Current state: + +- the UI exposes `bootstrapPromptTemplate` +- adapter execution paths do not currently use it +- several adapters prepend `instructionsFilePath` content directly into the per-run prompt or system prompt + +Result: + +- stable instructions are re-sent or re-applied in the same path as dynamic heartbeat content +- we are not deliberately optimizing for provider prompt caching + +### 5. We inject more skill surface than most agents need + +Local adapters inject repo skills into runtime skill directories. + +Important `codex_local` nuance: + +- Codex does not read skills directly from the active worktree. +- Paperclip discovers repo skills from the current checkout, then symlinks them into `$CODEX_HOME/skills` or `~/.codex/skills`. +- If an existing Paperclip skill symlink already points at another live checkout, the current implementation skips it instead of repointing it. +- This can leave Codex using stale skill content from a different worktree even after Paperclip-side skill changes land. +- That is both a correctness risk and a token-analysis risk, because runtime behavior may not reflect the instructions in the checkout being tested. + +Current repo skill sizes: + +- `skills/paperclip/SKILL.md`: 17,441 bytes +- `.agents/skills/create-agent-adapter/SKILL.md`: 31,832 bytes +- `skills/paperclip-create-agent/SKILL.md`: 4,718 bytes +- `skills/para-memory-files/SKILL.md`: 3,978 bytes + +That is nearly 58 KB of skill markdown before any company-specific instructions. + +Not all of that is necessarily loaded into model context every run, but it increases startup surface area and should be treated as a token budget concern. + +## Principles + +We should optimize tokens under these rules: + +1. **Do not lose functionality.** Agents must still be able to resume work safely, understand why tasks exist, and act within governance rules. +2. **Prefer stable context over repeated context.** Unchanged instructions should not be resent through the most expensive path. +3. **Prefer deltas over full reloads.** Heartbeats should consume only what changed since the last useful run. +4. **Measure normalized deltas, not raw adapter claims.** Especially for sessioned CLIs. +5. **Keep escape hatches.** Board/manual runs may still want a forced fresh session. + +## Plan + +## Phase 1: Make token telemetry trustworthy + +This should happen first. + +### Changes + +- Store both: + - raw adapter-reported usage + - Paperclip-normalized per-run usage +- For sessioned adapters, compute normalized deltas against prior usage for the same persisted session. +- Add explicit fields for: + - `sessionReused` + - `taskSessionReused` + - `promptChars` + - `instructionsChars` + - `hasInstructionsFile` + - `skillSetHash` or skill count + - `contextFetchMode` (`full`, `delta`, `summary`) +- Add per-adapter parser tests that distinguish cumulative-session counters from per-run counters. + +### Why + +Without this, we cannot tell whether a reduction came from a real optimization or a reporting artifact. + +### Success criteria + +- per-run token totals stop exploding on long-lived sessions +- a resumed session’s usage curve is believable and monotonic at the session level, but not double-counted at the run level +- cost pages can show both raw and normalized numbers while we migrate + +## Phase 2: Preserve safe session reuse by default + +This is the highest-leverage behavior change. + +### Changes + +- Stop resetting task sessions on ordinary timer wakes. +- Keep resetting on: + - explicit manual “fresh run” invocations + - assignment changes + - workspace mismatch + - model mismatch / invalid resume errors +- Add an explicit wake flag like `forceFreshSession: true` when the board wants a reset. +- Record why a session was reused or reset in run metadata. + +### Why + +Timer wakes are the dominant heartbeat path. Resetting them destroys both session continuity and prompt cache reuse. + +### Success criteria + +- timer wakes resume the prior task session in the large majority of stable-workspace cases +- no increase in stale-session failures +- lower normalized input tokens per timer heartbeat + +## Phase 3: Separate static bootstrap context from per-heartbeat context + +This is the right version of the discussion’s bootstrap idea. + +### Changes + +- Implement `bootstrapPromptTemplate` in adapter execution paths. +- Use it only when starting a fresh session, not on resumed sessions. +- Keep `promptTemplate` intentionally small and stable: + - who I am + - what triggered this wake + - which task/comment/approval to prioritize +- Move long-lived setup text out of recurring per-run prompts where possible. +- Add UI guidance and warnings when `promptTemplate` contains high-churn or large inline content. + +### Why + +Static instructions and dynamic wake context have different cache behavior and should be modeled separately. + +For `codex_local`, this also requires isolating the Codex skill home per worktree or teaching Paperclip to repoint its own skill symlinks when the source checkout changes. Otherwise prompt and skill improvements in the active worktree may not reach the running agent. + +### Success criteria + +- fresh-session prompts can remain richer without inflating every resumed heartbeat +- resumed prompts become short and structurally stable +- cache hit rates improve for session-preserving adapters + +## Phase 4: Make issue/task context incremental + +This is the biggest product change and likely the biggest real token saver after session reuse. + +### Changes + +Add heartbeat-oriented endpoints and skill behavior: + +- `GET /api/agents/me/inbox-lite` + - minimal assignment list + - issue id, identifier, status, priority, updatedAt, lastExternalCommentAt +- `GET /api/issues/:id/heartbeat-context` + - compact issue state + - parent-chain summary + - latest execution summary + - change markers +- `GET /api/issues/:id/comments?after=` or `?since=` + - return only new comments +- optional `GET /api/issues/:id/context-digest` + - server-generated compact summary for heartbeat use + +Update the `paperclip` skill so the default pattern becomes: + +1. fetch compact inbox +2. fetch compact task context +3. fetch only new comments unless this is the first read, a mention-triggered wake, or a cache miss +4. fetch full thread only on demand + +### Why + +Today we are using full-fidelity board APIs as heartbeat APIs. That is convenient but token-inefficient. + +### Success criteria + +- after first task acquisition, most heartbeats consume only deltas +- repeated blocked-task or long-thread work no longer replays the whole comment history +- mention-triggered wakes still have enough context to respond correctly + +## Phase 5: Add session compaction and controlled rotation + +This protects against long-lived session bloat. + +### Changes + +- Add rotation thresholds per adapter/session: + - turns + - normalized input tokens + - age + - cache hit degradation +- Before rotating, produce a structured carry-forward summary: + - current objective + - work completed + - open decisions + - blockers + - files/artifacts touched + - next recommended action +- Persist that summary in task session state or runtime state. +- Start the next session with: + - bootstrap prompt + - compact carry-forward summary + - current wake trigger + +### Why + +Even when reuse is desirable, some sessions become too expensive to keep alive indefinitely. + +### Success criteria + +- very long sessions stop growing without bound +- rotating a session does not cause loss of task continuity +- successful task completion rate stays flat or improves + +## Phase 6: Reduce unnecessary skill surface + +### Changes + +- Move from “inject all repo skills” to an allowlist per agent or per adapter. +- Default local runtime skill set should likely be: + - `paperclip` +- Add opt-in skills for specialized agents: + - `paperclip-create-agent` + - `para-memory-files` + - `create-agent-adapter` +- Expose active skill set in agent config and run metadata. +- For `codex_local`, either: + - run with a worktree-specific `CODEX_HOME`, or + - treat Paperclip-owned Codex skill symlinks as repairable when they point at a different checkout + +### Why + +Most agents do not need adapter-authoring or memory-system skills on every run. + +### Success criteria + +- smaller startup instruction surface +- no loss of capability for specialist agents that explicitly need extra skills + +## Rollout Order + +Recommended order: + +1. telemetry normalization +2. timer-wake session reuse +3. bootstrap prompt implementation +4. heartbeat delta APIs + `paperclip` skill rewrite +5. session compaction/rotation +6. skill allowlists + +## Acceptance Metrics + +We should treat this plan as successful only if we improve both efficiency and task outcomes. + +Primary metrics: + +- normalized input tokens per successful heartbeat +- normalized input tokens per completed issue +- cache-hit ratio for sessioned adapters +- session reuse rate by invocation source +- fraction of heartbeats that fetch full comment threads + +Guardrail metrics: + +- task completion rate +- blocked-task rate +- stale-session failure rate +- manual intervention rate +- issue reopen rate after agent completion + +Initial targets: + +- 30% to 50% reduction in normalized input tokens per successful resumed heartbeat +- 80%+ session reuse on stable timer wakes +- 80%+ reduction in full-thread comment reloads after first task read +- no statistically meaningful regression in completion rate or failure rate + +## Concrete Engineering Tasks + +1. Add normalized usage fields and migration support for run analytics. +2. Patch sessioned adapter accounting to compute deltas from prior session totals. +3. Change `shouldResetTaskSessionForWake(...)` so timer wakes do not reset by default. +4. Implement `bootstrapPromptTemplate` end-to-end in adapter execution. +5. Add compact heartbeat context and incremental comment APIs. +6. Rewrite `skills/paperclip/SKILL.md` around delta-fetch behavior. +7. Add session rotation with carry-forward summaries. +8. Replace global skill injection with explicit allowlists. +9. Fix `codex_local` skill resolution so worktree-local skill changes reliably reach the runtime. + +## Recommendation + +Treat this as a two-track effort: + +- **Track A: correctness and no-regret wins** + - telemetry normalization + - timer-wake session reuse + - bootstrap prompt implementation +- **Track B: structural token reduction** + - delta APIs + - skill rewrite + - session compaction + - skill allowlists + +If we only do Track A, we will improve things, but agents will still re-read too much unchanged task context. + +If we only do Track B without fixing telemetry first, we will not be able to prove the gains cleanly. diff --git a/doc/plans/2026-03-13-agent-evals-framework.md b/doc/plans/2026-03-13-agent-evals-framework.md new file mode 100644 index 00000000..6c4cc55e --- /dev/null +++ b/doc/plans/2026-03-13-agent-evals-framework.md @@ -0,0 +1,775 @@ +# Agent Evals Framework Plan + +Date: 2026-03-13 + +## Context + +We need evals for the thing Paperclip actually ships: + +- agent behavior produced by adapter config +- prompt templates and bootstrap prompts +- skill sets and skill instructions +- model choice +- runtime policy choices that affect outcomes and cost + +We do **not** primarily need a fine-tuning pipeline. +We need a regression framework that can answer: + +- if we change prompts or skills, do agents still do the right thing? +- if we switch models, what got better, worse, or more expensive? +- if we optimize tokens, did we preserve task outcomes? +- can we grow the suite over time from real Paperclip usage? + +This plan is based on: + +- `doc/GOAL.md` +- `doc/PRODUCT.md` +- `doc/SPEC-implementation.md` +- `docs/agents-runtime.md` +- `doc/plans/2026-03-13-TOKEN-OPTIMIZATION-PLAN.md` +- Discussion #449: +- OpenAI eval best practices: +- Promptfoo docs: and +- LangSmith complex agent eval docs: +- Braintrust dataset/scorer docs: and + +## Recommendation + +Paperclip should take a **two-stage approach**: + +1. **Start with Promptfoo now** for narrow, prompt-and-skill behavior evals across models. +2. **Grow toward a first-party, repo-local eval harness in TypeScript** for full Paperclip scenario evals. + +So the recommendation is no longer “skip Promptfoo.” It is: + +- use Promptfoo as the fastest bootstrap layer +- keep eval cases and fixtures in this repo +- avoid making Promptfoo config the deepest long-term abstraction + +More specifically: + +1. The canonical eval definitions should live in this repo under a top-level `evals/` directory. +2. `v0` should use Promptfoo to run focused test cases across models and providers. +3. The longer-term harness should run **real Paperclip scenarios** against seeded companies/issues/agents, not just raw prompt completions. +4. The scoring model should combine: + - deterministic checks + - structured rubric scoring + - pairwise candidate-vs-baseline judging + - efficiency metrics from normalized usage/cost telemetry +5. The framework should compare **bundles**, not just models. + +A bundle is: + +- adapter type +- model id +- prompt template(s) +- bootstrap prompt template +- skill allowlist / skill content version +- relevant runtime flags + +That is the right unit because that is what actually changes behavior in Paperclip. + +## Why This Is The Right Shape + +### 1. We need to evaluate system behavior, not only prompt output + +Prompt-only tools are useful, but Paperclip’s real failure modes are often: + +- wrong issue chosen +- wrong API call sequence +- bad delegation +- failure to respect approval boundaries +- stale session behavior +- over-reading context +- claiming completion without producing artifacts or comments + +Those are control-plane behaviors. They require scenario setup, execution, and trace inspection. + +### 2. The repo is already TypeScript-first + +The existing monorepo already uses: + +- `pnpm` +- `tsx` +- `vitest` +- TypeScript across server, UI, shared contracts, and adapters + +A TypeScript-first harness will fit the repo and CI better than introducing a Python-first test subsystem as the default path. + +Python can stay optional later for specialty scorers or research experiments. + +### 3. We need provider/model comparison without vendor lock-in + +OpenAI’s guidance is directionally right: + +- eval early and often +- use task-specific evals +- log everything +- prefer pairwise/comparison-style judging over open-ended scoring + +But OpenAI’s Evals API is not the right control plane for Paperclip as the primary system because our target is explicitly multi-model and multi-provider. + +### 4. Hosted eval products are useful, and Promptfoo is the right bootstrap tool + +The current tradeoff: + +- Promptfoo is very good for local, repo-based prompt/provider matrices and CI integration. +- LangSmith is strong on trajectory-style agent evals. +- Braintrust has a clean dataset + scorer + experiment model and strong TypeScript support. + +The community suggestion is directionally right: + +- Promptfoo lets us start small +- it supports simple assertions like contains / not-contains / regex / custom JS +- it can run the same cases across multiple models +- it supports OpenRouter +- it can move into CI later + +That makes it the best `v0` tool for “did this prompt/skill/model change obviously regress?” + +But Paperclip should still avoid making a hosted platform or a third-party config format the core abstraction before we have our own stable eval model. + +The right move is: + +- start with Promptfoo for quick wins +- keep the data portable and repo-owned +- build a thin first-party harness around Paperclip concepts as the system grows +- optionally export to or integrate with other tools later if useful + +## What We Should Evaluate + +We should split evals into four layers. + +### Layer 1: Deterministic contract evals + +These should require no judge model. + +Examples: + +- agent comments on the assigned issue +- no mutation outside the agent’s company +- approval-required actions do not bypass approval flow +- task transitions are legal +- output contains required structured fields +- artifact links exist when the task required an artifact +- no full-thread refetch on delta-only cases once the API supports it + +These are cheap, reliable, and should be the first line of defense. + +### Layer 2: Single-step behavior evals + +These test narrow behaviors in isolation. + +Examples: + +- chooses the correct issue from inbox +- writes a reasonable first status comment +- decides to ask for approval instead of acting directly +- delegates to the correct report +- recognizes blocked state and reports it clearly + +These are the closest thing to prompt evals, but still framed in Paperclip terms. + +### Layer 3: End-to-end scenario evals + +These run a full heartbeat or short sequence of heartbeats against a seeded scenario. + +Examples: + +- new assignment pickup +- long-thread continuation +- mention-triggered clarification +- approval-gated hire request +- manager escalation +- workspace coding task that must leave a meaningful issue update + +These should evaluate both final state and trace quality. + +### Layer 4: Efficiency and regression evals + +These are not “did the answer look good?” evals. They are “did we preserve quality while improving cost/latency?” evals. + +Examples: + +- normalized input tokens per successful heartbeat +- normalized tokens per completed issue +- session reuse rate +- full-thread reload rate +- wall-clock duration +- cost per successful scenario + +This layer is especially important for token optimization work. + +## Core Design + +## 1. Canonical object: `EvalCase` + +Each eval case should define: + +- scenario setup +- target bundle(s) +- execution mode +- expected invariants +- scoring rubric +- tags/metadata + +Suggested shape: + +```ts +type EvalCase = { + id: string; + description: string; + tags: string[]; + setup: { + fixture: string; + agentId: string; + trigger: "assignment" | "timer" | "on_demand" | "comment" | "approval"; + }; + inputs?: Record; + checks: { + hard: HardCheck[]; + rubric?: RubricCheck[]; + pairwise?: PairwiseCheck[]; + }; + metrics: MetricSpec[]; +}; +``` + +The important part is that the case is about a Paperclip scenario, not a standalone prompt string. + +## 2. Canonical object: `EvalBundle` + +Suggested shape: + +```ts +type EvalBundle = { + id: string; + adapter: string; + model: string; + promptTemplate: string; + bootstrapPromptTemplate?: string; + skills: string[]; + flags?: Record; +}; +``` + +Every comparison run should say which bundle was tested. + +This avoids the common mistake of saying “model X is better” when the real change was model + prompt + skills + runtime behavior. + +## 3. Canonical output: `EvalTrace` + +We should capture a normalized trace for scoring: + +- run ids +- prompts actually sent +- session reuse metadata +- issue mutations +- comments created +- approvals requested +- artifacts created +- token/cost telemetry +- timing +- raw outputs + +The scorer layer should never need to scrape ad hoc logs. + +## Scoring Framework + +## 1. Hard checks first + +Every eval should start with pass/fail checks that can invalidate the run immediately. + +Examples: + +- touched wrong company +- skipped required approval +- no issue update produced +- returned malformed structured output +- marked task done without required artifact + +If a hard check fails, the scenario fails regardless of style or judge score. + +## 2. Rubric scoring second + +Rubric scoring should use narrow criteria, not vague “how good was this?” prompts. + +Good rubric dimensions: + +- task understanding +- governance compliance +- useful progress communication +- correct delegation +- evidence of completion +- concision / unnecessary verbosity + +Each rubric should be a small 0-1 or 0-2 decision, not a mushy 1-10 scale. + +## 3. Pairwise judging for candidate vs baseline + +OpenAI’s eval guidance is right that LLMs are better at discrimination than open-ended generation. + +So for non-deterministic quality checks, the default pattern should be: + +- run baseline bundle on the case +- run candidate bundle on the same case +- ask a judge model which is better on explicit criteria +- allow `baseline`, `candidate`, or `tie` + +This is better than asking a judge for an absolute quality score with no anchor. + +## 4. Efficiency scoring is separate + +Do not bury efficiency inside a single blended quality score. + +Record it separately: + +- quality score +- cost score +- latency score + +Then compute a summary decision such as: + +- candidate is acceptable only if quality is non-inferior and efficiency is improved + +That is much easier to reason about than one magic number. + +## Suggested Decision Rule + +For PR gating: + +1. No hard-check regressions. +2. No significant regression on required scenario pass rate. +3. No significant regression on key rubric dimensions. +4. If the change is token-optimization-oriented, require efficiency improvement on target scenarios. + +For deeper comparison reports, show: + +- pass rate +- pairwise wins/losses/ties +- median normalized tokens +- median wall-clock time +- cost deltas + +## Dataset Strategy + +We should explicitly build the dataset from three sources. + +### 1. Hand-authored seed cases + +Start here. + +These should cover core product invariants: + +- assignment pickup +- status update +- blocked reporting +- delegation +- approval request +- cross-company access denial +- issue comment follow-up + +These are small, clear, and stable. + +### 2. Production-derived cases + +Per OpenAI’s guidance, we should log everything and mine real usage for eval cases. + +Paperclip should grow eval coverage by promoting real runs into cases when we see: + +- regressions +- interesting failures +- edge cases +- high-value success patterns worth preserving + +The initial version can be manual: + +- take a real run +- redact/normalize it +- convert it into an `EvalCase` + +Later we can automate trace-to-case generation. + +### 3. Adversarial and guardrail cases + +These should intentionally probe failure modes: + +- approval bypass attempts +- wrong-company references +- stale context traps +- irrelevant long threads +- misleading instructions in comments +- verbosity traps + +This is where promptfoo-style red-team ideas can become useful later, but it is not the first slice. + +## Repo Layout + +Recommended initial layout: + +```text +evals/ + README.md + promptfoo/ + promptfooconfig.yaml + prompts/ + cases/ + cases/ + core/ + approvals/ + delegation/ + efficiency/ + fixtures/ + companies/ + issues/ + bundles/ + baseline/ + experiments/ + runners/ + scenario-runner.ts + compare-runner.ts + scorers/ + hard/ + rubric/ + pairwise/ + judges/ + rubric-judge.ts + pairwise-judge.ts + lib/ + types.ts + traces.ts + metrics.ts + reports/ + .gitignore +``` + +Why top-level `evals/`: + +- it makes evals feel first-class +- it avoids hiding them inside `server/` even though they span adapters and runtime behavior +- it leaves room for both TS and optional Python helpers later +- it gives us a clean place for Promptfoo `v0` config plus the later first-party runner + +## Execution Model + +The harness should support three modes. + +### Mode A: Cheap local smoke + +Purpose: + +- run on PRs +- keep cost low +- catch obvious regressions + +Characteristics: + +- 5 to 20 cases +- 1 or 2 bundles +- mostly hard checks and narrow rubrics + +### Mode B: Candidate vs baseline compare + +Purpose: + +- evaluate a prompt/skill/model change before merge + +Characteristics: + +- paired runs +- pairwise judging enabled +- quality + efficiency diff report + +### Mode C: Nightly broader matrix + +Purpose: + +- compare multiple models and bundles +- grow historical benchmark data + +Characteristics: + +- larger case set +- multiple models +- more expensive rubric/pairwise judging + +## CI and Developer Workflow + +Suggested commands: + +```sh +pnpm evals:smoke +pnpm evals:compare --baseline baseline/codex-default --candidate experiments/codex-lean-skillset +pnpm evals:nightly +``` + +PR behavior: + +- run `evals:smoke` on prompt/skill/adapter/runtime changes +- optionally trigger `evals:compare` for labeled PRs or manual runs + +Nightly behavior: + +- run larger matrix +- save report artifact +- surface trend lines on pass rate, pairwise wins, and efficiency + +## Framework Comparison + +## Promptfoo + +Best use for Paperclip: + +- prompt-level micro-evals +- provider/model comparison +- quick local CI integration +- custom JS assertions and custom providers +- bootstrap-layer evals for one skill or one agent workflow + +What changed in this recommendation: + +- Promptfoo is now the recommended **starting point** +- especially for “one skill, a handful of cases, compare across models” + +Why it still should not be the only long-term system: + +- its primary abstraction is still prompt/provider/test-case oriented +- Paperclip needs scenario setup, control-plane state inspection, and multi-step traces as first-class concepts + +Recommendation: + +- use Promptfoo first +- store Promptfoo config and cases in-repo under `evals/promptfoo/` +- use custom JS/TS assertions and, if needed later, a custom provider that calls Paperclip scenario runners +- do not make Promptfoo YAML the only canonical Paperclip eval format once we outgrow prompt-level evals + +## LangSmith + +What it gets right: + +- final response evals +- trajectory evals +- single-step evals + +Why not the primary system today: + +- stronger fit for teams already centered on LangChain/LangGraph +- introduces hosted/external workflow gravity before our own eval model is stable + +Recommendation: + +- copy the trajectory/final/single-step taxonomy +- do not adopt the platform as the default requirement + +## Braintrust + +What it gets right: + +- TypeScript support +- clean dataset/task/scorer model +- production logging to datasets +- experiment comparison over time + +Why not the primary system today: + +- still externalizes the canonical dataset and review workflow +- we are not yet at the maturity where hosted experiment management should define the shape of the system + +Recommendation: + +- borrow its dataset/scorer/experiment mental model +- revisit once we want hosted review and experiment history at scale + +## OpenAI Evals / Evals API + +What it gets right: + +- strong eval principles +- emphasis on task-specific evals +- continuous evaluation mindset + +Why not the primary system: + +- Paperclip must compare across models/providers +- we do not want our primary eval runner coupled to one model vendor + +Recommendation: + +- use the guidance +- do not use it as the core Paperclip eval runtime + +## First Implementation Slice + +The first version should be intentionally small. + +## Phase 0: Promptfoo bootstrap + +Build: + +- `evals/promptfoo/promptfooconfig.yaml` +- 5 to 10 focused cases for one skill or one agent workflow +- model matrix using the providers we care about most +- mostly deterministic assertions: + - contains + - not-contains + - regex + - custom JS assertions + +Target scope: + +- one skill, or one narrow workflow such as assignment pickup / first status update +- compare a small set of bundles across several models + +Success criteria: + +- we can run one command and compare outputs across models +- prompt/skill regressions become visible quickly +- the team gets signal before building heavier infrastructure + +## Phase 1: Skeleton and core cases + +Build: + +- `evals/` scaffold +- `EvalCase`, `EvalBundle`, `EvalTrace` types +- scenario runner for seeded local cases +- 10 hand-authored core cases +- hard checks only + +Target cases: + +- assigned issue pickup +- write progress comment +- ask for approval when required +- respect company boundary +- report blocked state +- avoid marking done without artifact/comment evidence + +Success criteria: + +- a developer can run a local smoke suite +- prompt/skill changes can fail the suite deterministically +- Promptfoo `v0` cases either migrate into or coexist with this layer cleanly + +## Phase 2: Pairwise and rubric layer + +Build: + +- rubric scorer interface +- pairwise judge runner +- candidate vs baseline compare command +- markdown/html report output + +Success criteria: + +- model/prompt bundle changes produce a readable diff report +- we can tell “better”, “worse”, or “same” on curated scenarios + +## Phase 3: Efficiency integration + +Build: + +- normalized token/cost metrics into eval traces +- cost and latency comparisons +- efficiency gates for token optimization work + +Dependency: + +- this should align with the telemetry normalization work in `2026-03-13-TOKEN-OPTIMIZATION-PLAN.md` + +Success criteria: + +- quality and efficiency can be judged together +- token-reduction work no longer relies on anecdotal improvements + +## Phase 4: Production-case ingestion + +Build: + +- tooling to promote real runs into new eval cases +- metadata tagging +- failure corpus growth process + +Success criteria: + +- the eval suite grows from real product behavior instead of staying synthetic + +## Initial Case Categories + +We should start with these categories: + +1. `core.assignment_pickup` +2. `core.progress_update` +3. `core.blocked_reporting` +4. `governance.approval_required` +5. `governance.company_boundary` +6. `delegation.correct_report` +7. `threads.long_context_followup` +8. `efficiency.no_unnecessary_reloads` + +That is enough to start catching the classes of regressions we actually care about. + +## Important Guardrails + +### 1. Do not rely on judge models alone + +Every important scenario needs deterministic checks first. + +### 2. Do not gate PRs on a single noisy score + +Use pass/fail invariants plus a small number of stable rubric or pairwise checks. + +### 3. Do not confuse benchmark score with product quality + +The suite must keep growing from real runs, otherwise it will become a toy benchmark. + +### 4. Do not evaluate only final output + +Trajectory matters for agents: + +- did they call the right Paperclip APIs? +- did they ask for approval? +- did they communicate progress? +- did they choose the right issue? + +### 5. Do not make the framework vendor-shaped + +Our eval model should survive changes in: + +- judge provider +- candidate provider +- adapter implementation +- hosted tooling choices + +## Open Questions + +1. Should the first scenario runner invoke the real server over HTTP, or call services directly in-process? + My recommendation: start in-process for speed, then add HTTP-mode coverage once the model stabilizes. + +2. Should we support Python scorers in v1? + My recommendation: no. Keep v1 all-TypeScript. + +3. Should we commit baseline outputs? + My recommendation: commit case definitions and bundle definitions, but keep run artifacts out of git. + +4. Should we add hosted experiment tracking immediately? + My recommendation: no. Revisit after the local harness proves useful. + +## Final Recommendation + +Start with Promptfoo for immediate, narrow model-and-prompt comparisons, then grow into a first-party `evals/` framework in TypeScript that evaluates **Paperclip scenarios and bundles**, not just prompts. + +Use this structure: + +- Promptfoo for `v0` bootstrap +- deterministic hard checks as the foundation +- rubric and pairwise judging for non-deterministic quality +- normalized efficiency metrics as a separate axis +- repo-local datasets that grow from real runs + +Use external tools selectively: + +- Promptfoo as the initial path for narrow prompt/provider tests +- Braintrust or LangSmith later if we want hosted experiment management + +But keep the canonical eval model inside the Paperclip repo and aligned to Paperclip’s actual control-plane behaviors. diff --git a/doc/plans/2026-03-13-features.md b/doc/plans/2026-03-13-features.md new file mode 100644 index 00000000..80c60a87 --- /dev/null +++ b/doc/plans/2026-03-13-features.md @@ -0,0 +1,780 @@ +# Feature specs + +## 1) Guided onboarding + first-job magic + +The repo already has `onboard`, `doctor`, `run`, deployment modes, and even agent-oriented onboarding text/skills endpoints, but there are also current onboarding/auth validation issues and an open “onboard failed” report. That means this is not just polish; it is product-critical. ([GitHub][1]) + +### Product decision + +Replace “configuration-first onboarding” with **interview-first onboarding**. + +### What we want + +- Ask 3–4 questions up front, not 20 settings. +- Generate the right path automatically: local solo, shared private, or public cloud. +- Detect what agent/runtime environment already exists. +- Make it normal to have Claude/OpenClaw/Codex help complete setup. +- End onboarding with a **real first task**, not a blank dashboard. + +### What we do not want + +- Provider jargon before value. +- “Go find an API key” as the default first instruction. +- A successful install that still leaves users unsure what to do next. + +### Proposed UX + +On first run, show an interview: + +```ts +type OnboardingProfile = { + useCase: "startup" | "agency" | "internal_team"; + companySource: "new" | "existing"; + deployMode: "local_solo" | "shared_private" | "shared_public"; + autonomyMode: "hands_on" | "hybrid" | "full_auto"; + primaryRuntime: "claude_code" | "codex" | "openclaw" | "other"; +}; +``` + +Questions: + +1. What are you building? +2. Is this a new company, an existing company, or a service/agency team? +3. Are you working solo on one machine, sharing privately with a team, or deploying publicly? +4. Do you want full auto, hybrid, or tight manual control? + +Then Paperclip should: + +- detect installed CLIs/providers/subscriptions +- recommend the matching deployment/auth mode +- generate a local `onboarding.txt` / LLM handoff prompt +- offer a button: **“Open this in Claude / copy setup prompt”** +- create starter objects: + + - company + - company goal + - CEO + - founding engineer or equivalent first report + - first suggested task + +### Backend / API + +- Add `GET /api/onboarding/recommendation` +- Add `GET /api/onboarding/llm-handoff.txt` +- Reuse existing invite/onboarding/skills patterns for local-first bootstrap +- Persist onboarding answers into instance config for later defaults + +### Acceptance criteria + +- Fresh install with a supported local runtime completes without manual JSON/env editing. +- User sees first live agent action before leaving onboarding. +- A blank dashboard is no longer the default post-install state. +- If a required dependency is missing, the error is prescriptive and fixable from the UI/CLI. + +### Non-goals + +- Account creation +- enterprise SSO +- perfect provider auto-detection for every runtime + +--- + +## 2) Board command surface, not generic chat + +There is a real tension here: the transcript says users want “chat with my CEO,” while the public product definition says Paperclip is **not a chatbot** and V1 communication is **tasks + comments only**. At the same time, the repo is already exploring plugin infrastructure and even a chat plugin via plugin SSE streaming. The clean resolution is: **make the core surface conversational, but keep the data model task/thread-centric; reserve full chat as an optional plugin**. ([GitHub][2]) + +### Product decision + +Build a **Command Composer** backed by issues/comments/approvals, not a separate chat subsystem. + +### What we want + +- “Talk to the CEO” feeling for the user. +- Every conversation ends up attached to a real company object. +- Strategy discussion can produce issues, artifacts, and approvals. + +### What we do not want + +- A blank “chat with AI” home screen disconnected from the org. +- Yet another agent-chat product. + +### Proposed UX + +Add a global composer with modes: + +```ts +type ComposerMode = "ask" | "task" | "decision"; +type ThreadScope = "company" | "project" | "issue" | "agent"; +``` + +Examples: + +- On dashboard: “Ask the CEO for a hiring plan” → creates a `strategy` issue/thread scoped to the company. +- On agent page: “Tell the designer to make this cleaner” → appends an instruction comment to an issue or spawns a new delegated task. +- On approval page: “Why are you asking to hire?” → appends a board comment to the approval context. + +Add issue kinds: + +```ts +type IssueKind = "task" | "strategy" | "question" | "decision"; +``` + +### Backend / data model + +Prefer extending existing `issues` rather than creating `chats`: + +- `issues.kind` +- `issues.scope` +- optional `issues.target_agent_id` +- comment metadata: `comment.intent = hint | correction | board_question | board_decision` + +### Acceptance criteria + +- A user can “ask CEO” from the dashboard and receive a response in a company-scoped thread. +- From that thread, the user can create/approve tasks with one click. +- No separate chat database is required for v1 of this feature. + +### Non-goals + +- consumer chat UX +- model marketplace +- general-purpose assistant unrelated to company context + +--- + +## 3) Live org visibility + explainability layer + +The core product promise is already visibility and governance, but right now the transcript makes clear that the UI is still too close to raw agent execution. The repo already has org charts, activity, heartbeat runs, costs, and agent detail surfaces; the missing piece is the explanatory layer above them. ([GitHub][1]) + +### Product decision + +Default the UI to **human-readable operational summaries**, with raw logs one layer down. + +### What we want + +- At company level: “who is active, what are they doing, what is moving between teams” +- At agent level: “what is the plan, what step is complete, what outputs were produced” +- At run level: “summary first, transcript second” + +### Proposed UX + +Company page: + +- org chart with live active-state indicators +- delegation animation between nodes when work moves +- current open priorities +- pending approvals +- burn / budget warning strip + +Agent page: + +- status card +- current issue +- plan checklist +- latest artifact(s) +- summary of last run +- expandable raw trace/logs + +Run page: + +- **Summary** +- **Steps** +- **Raw transcript / tool calls** + +### Backend / API + +Generate a run view model from current run/activity data: + +```ts +type RunSummary = { + runId: string; + headline: string; + objective: string | null; + currentStep: string | null; + completedSteps: string[]; + delegatedTo: { agentId: string; issueId?: string }[]; + artifactIds: string[]; + warnings: string[]; +}; +``` + +Phase 1 can derive this server-side from existing run logs/comments. Persist only if needed later. + +### Acceptance criteria + +- Board can tell what is happening without reading shell commands. +- Raw logs are still accessible, but not the default surface. +- First task / first hire / first completion moments are visibly celebrated. + +### Non-goals + +- overdesigned animation system +- perfect semantic summarization before core data quality exists + +--- + +## 4) Artifact system: attachments, file browser, previews + +This gap is already showing up in the repo. Storage is present, attachment endpoints exist, but current issues show that attachments are still effectively image-centric and comment attachment rendering is incomplete. At the same time, your transcript wants plans, docs, files, and generated web pages surfaced cleanly. ([GitHub][4]) + +### Product decision + +Introduce a first-class **Artifact** model that unifies: + +- uploaded/generated files +- workspace files of interest +- preview URLs +- generated docs/reports + +### What we want + +- Plans, specs, CSVs, markdown, PDFs, logs, JSON, HTML outputs +- easy discoverability from the issue/run/company pages +- a lightweight file browser for project workspaces +- preview links for generated websites/apps + +### What we do not want + +- forcing agents to paste everything inline into comments +- HTML stuffed into comment bodies as a workaround +- a full web IDE + +### Phase 1: fix the obvious gaps + +- Accept non-image MIME types for issue attachments +- Attach files to comments correctly +- Show file metadata + download/open on issue page + +### Phase 2: introduce artifacts + +```ts +type ArtifactKind = "attachment" | "workspace_file" | "preview" | "report_link"; + +interface Artifact { + id: string; + companyId: string; + issueId?: string; + runId?: string; + agentId?: string; + kind: ArtifactKind; + title: string; + mimeType?: string; + filename?: string; + sizeBytes?: number; + storageKind: "local_disk" | "s3" | "external_url"; + contentPath?: string; + previewUrl?: string; + metadata: Record; +} +``` + +### UX + +Issue page gets a **Deliverables** section: + +- Files +- Reports +- Preview links +- Latest generated artifact highlighted at top + +Project page gets a **Files** tab: + +- folder tree +- recent changes +- “Open produced files” shortcut + +### Preview handling + +For HTML/static outputs: + +- local deploy → open local preview URL +- shared/public deploy → host via configured preview service or static storage +- preview URL is registered back onto the issue as an artifact + +### Acceptance criteria + +- Agents can attach `.md`, `.txt`, `.json`, `.csv`, `.pdf`, and `.html`. +- Users can open/download them from the issue page. +- A generated static site can be opened from an issue without hunting through the filesystem. + +### Non-goals + +- browser IDE +- collaborative docs editor +- full object-storage admin UI + +--- + +## 5) Shared/cloud deployment + cloud runtimes + +The repo already has a clear deployment story in docs: `local_trusted`, `authenticated/private`, and `authenticated/public`, plus Tailscale guidance. The roadmap explicitly calls out cloud agents like Cursor / e2b. That means the next step is not inventing a deployment model; it is making the shared/cloud path canonical and production-usable. ([GitHub][5]) + +### Product decision + +Make **shared/private deploy** and **public/cloud deploy** first-class supported modes, and add **remote runtime drivers** for cloud-executed agents. + +### What we want + +- one instance a team can actually share +- local-first path that upgrades to private/public without a mental model change +- remote agent execution for non-local runtimes + +### Proposed architecture + +Separate **control plane** from **execution runtime** more explicitly: + +```ts +type RuntimeDriver = "local_process" | "remote_sandbox" | "webhook"; + +interface ExecutionHandle { + externalRunId: string; + status: "queued" | "running" | "completed" | "failed" | "cancelled"; + previewUrl?: string; + logsUrl?: string; +} +``` + +First remote driver: `remote_sandbox` for e2b-style execution. + +### Deliverables + +- canonical deploy recipes: + + - local solo + - shared private (Tailscale/private auth) + - public cloud (managed Postgres + object storage + public URL) + +- runtime health page +- adapter/runtime capability matrix +- one official reference deployment path + +### UX + +New “Deployment” settings page: + +- instance mode +- auth/exposure +- storage/database status +- runtime drivers configured +- health and reachability checks + +### Acceptance criteria + +- Two humans can log into one authenticated/private instance and use it concurrently. +- A public deployment can run agents via at least one remote runtime. +- `doctor` catches missing public/private config and gives concrete fixes. + +### Non-goals + +- fully managed Paperclip SaaS +- every possible cloud provider in v1 + +--- + +## 6) Multi-human collaboration (minimal, not enterprise RBAC) + +This is the biggest deliberate departure from the current V1 spec. Publicly, V1 still says “single human board operator” and puts role-based human granularity out of scope. But the transcript is right that shared use is necessary if Paperclip is going to be real for teams. The key is to do a **minimal collaboration model**, not a giant permission system. ([GitHub][2]) + +### Product decision + +Ship **coarse multi-user company memberships**, not fine-grained enterprise RBAC. + +### Proposed roles + +```ts +type CompanyRole = "owner" | "admin" | "operator" | "viewer"; +``` + +- **owner**: instance/company ownership, user invites, config +- **admin**: manage org, agents, budgets, approvals +- **operator**: create/update issues, interact with agents, view artifacts +- **viewer**: read-only + +### Data model + +```ts +interface CompanyMembership { + userId: string; + companyId: string; + role: CompanyRole; + invitedByUserId: string; + createdAt: string; +} +``` + +Stretch goal later: + +- optional project/team scoping + +### What we want + +- shared dashboard for real teams +- user attribution in activity log +- simple invite flow +- company-level isolation preserved + +### What we do not want + +- per-field ACLs +- SCIM/SSO/enterprise admin consoles +- ten permission toggles per page + +### Acceptance criteria + +- Team of 3 can use one shared Paperclip instance. +- Every user action is attributed correctly in activity. +- Company membership boundaries are enforced. +- Viewer cannot mutate; operator/admin can. + +### Non-goals + +- enterprise RBAC +- cross-company matrix permissions +- multi-board governance logic in first cut + +--- + +## 7) Auto mode + interrupt/resume + +This is a product behavior issue, not a UI nicety. If agents cannot keep working or accept course correction without restarting, the autonomy model feels fake. + +### Product decision + +Make auto mode and mid-run interruption first-class runtime semantics. + +### What we want + +- Auto mode that continues until blocked by approvals, budgets, or explicit pause. +- Mid-run “you missed this” correction without losing session continuity. +- Clear state when an agent is waiting, blocked, or paused. + +### Proposed state model + +```ts +type RunState = + | "queued" + | "running" + | "waiting_approval" + | "waiting_input" + | "paused" + | "completed" + | "failed" + | "cancelled"; +``` + +Add board interjections as resumable input events: + +```ts +interface RunMessage { + runId: string; + authorUserId: string; + mode: "hint" | "correction" | "hard_override"; + body: string; + resumeCurrentSession: boolean; +} +``` + +### UX + +Buttons on active run: + +- Pause +- Resume +- Interrupt +- Abort +- Restart from scratch + +Interrupt opens a small composer that explicitly says: + +- continue current session +- or restart run + +### Acceptance criteria + +- A board comment can resume an active session instead of spawning a fresh one. +- Session ID remains stable for “continue” path. +- UI clearly distinguishes blocked vs. waiting vs. paused. + +### Non-goals + +- simultaneous multi-user live editing of the same run transcript +- perfect conversational UX before runtime semantics are fixed + +--- + +## 8) Cost safety + heartbeat/runtime hardening + +This is probably the most important immediate workstream. The transcript says token burn is the highest pain, and the repo currently has active issues around budget enforcement evidence, onboarding/auth validation, and circuit-breaker style waste prevention. Public docs already promise hard budgets, and the issue tracker is pointing at the missing operational protections. ([GitHub][6]) + +### Product decision + +Treat this as a **P0 runtime contract**, not a nice-to-have. + +### Part A: deterministic wake gating + +Do cheap, explicit work detection before invoking an LLM. + +```ts +type WakeReason = + | "new_assignment" + | "new_comment" + | "mention" + | "approval_resolved" + | "scheduled_scan" + | "manual"; +``` + +Rules: + +- if no new actionable input exists, do not call the model +- scheduled scan should be a cheap policy check first, not a full reasoning pass + +### Part B: budget contract + +Keep the existing public promise, but make it undeniable: + +- warning at 80% +- auto-pause at 100% +- visible audit trail +- explicit board override to continue + +### Part C: circuit breaker + +Add per-agent runtime guards: + +```ts +interface CircuitBreakerConfig { + enabled: boolean; + maxConsecutiveNoProgress: number; + maxConsecutiveFailures: number; + tokenVelocityMultiplier: number; +} +``` + +Trip when: + +- no issue/status/comment progress for N runs +- N failures in a row +- token spike vs rolling average + +### Part D: refactor heartbeat service + +Split current orchestration into modules: + +- wake detector +- checkout/lock manager +- adapter runner +- session manager +- cost recorder +- breaker evaluator +- event streamer + +### Part E: regression suite + +Mandatory automated proofs for: + +- onboarding/auth matrix +- 80/100 budget behavior +- no cross-company auth leakage +- no-spurious-wake idle behavior +- active-run resume/interruption +- remote runtime smoke + +### Acceptance criteria + +- Idle org with no new work does not generate model calls from heartbeat scans. +- 80% shows warning only. +- 100% pauses the agent and blocks continued execution until override. +- Circuit breaker pause is visible in audit/activity. +- Runtime modules have explicit contracts and are testable independently. + +### Non-goals + +- perfect autonomous optimization +- eliminating all wasted calls in every adapter/provider + +--- + +## 9) Project workspaces, previews, and PR handoff — without becoming GitHub + +This is the right way to resolve the code-workflow debate. The repo already has worktree-local instances, project `workspaceStrategy.provisionCommand`, and an RFC for adapter-level git worktree isolation. That is the correct architectural direction: **project execution policies and workspace isolation**, not built-in PR review. ([GitHub][7]) + +### Product decision + +Paperclip should manage the **issue → workspace → preview/PR → review handoff** lifecycle, but leave diffs/review/merge to external tools. + +### Proposed config + +Prefer repo-local project config: + +```yaml +# .paperclip/project.yml +execution: + workspaceStrategy: shared | worktree | ephemeral_container + deliveryMode: artifact | preview | pull_request + provisionCommand: "pnpm install" + teardownCommand: "pnpm clean" + preview: + command: "pnpm dev --port $PAPERCLIP_PREVIEW_PORT" + healthPath: "/" + ttlMinutes: 120 + vcs: + provider: github + repo: owner/repo + prPerIssue: true + baseBranch: main +``` + +### Rules + +- For non-code projects: `deliveryMode=artifact` +- For UI/app work: `deliveryMode=preview` +- For git-backed engineering projects: `deliveryMode=pull_request` +- For git-backed projects with `prPerIssue=true`, one issue maps to one isolated branch/worktree + +### UX + +Issue page shows: + +- workspace link/status +- preview URL if available +- PR URL if created +- “Reopen preview” button with TTL +- lifecycle: + + - `todo` + - `in_progress` + - `in_review` + - `done` + +### What we want + +- safe parallel agent work on one repo +- previewable output +- external PR review +- project-defined hooks, not hardcoded assumptions + +### What we do not want + +- built-in diff viewer +- merge queue +- Jira clone +- mandatory PRs for non-code work + +### Acceptance criteria + +- Multiple engineer agents can work concurrently without workspace contamination. +- When a project is in PR mode, the issue contains branch/worktree/preview/PR metadata. +- Preview can be reopened on demand until TTL expires. + +### Non-goals + +- replacing GitHub/GitLab +- universal preview hosting for every framework on day one + +--- + +## 10) Plugin system as the escape hatch + +The roadmap already includes plugins, GitHub discussions are active around it, and there is an open issue proposing an SSE bridge specifically to enable streaming plugin UIs such as chat, logs, and monitors. This is exactly the right place for optional surfaces. ([GitHub][1]) + +### Product decision + +Keep the control-plane core thin; put optional high-variance experiences into plugins. + +### First-party plugin targets + +- Chat +- Knowledge base / RAG +- Log tail / live build output +- Custom tracing or queues +- Doc editor / proposal builder + +### Plugin manifest + +```ts +interface PluginManifest { + id: string; + version: string; + requestedPermissions: ( + | "read_company" + | "read_issue" + | "write_issue_comment" + | "create_issue" + | "stream_ui" + )[]; + surfaces: ("company_home" | "issue_panel" | "agent_panel" | "sidebar")[]; + workerEntry: string; + uiEntry: string; +} +``` + +### Platform requirements + +- host ↔ worker action bridge +- SSE/UI streaming +- company-scoped auth +- permission declaration +- surface slots in UI + +### Acceptance criteria + +- A plugin can stream events to UI in real time. +- A chat plugin can converse without requiring chat to become the core Paperclip product. +- Plugin permissions are company-scoped and auditable. + +### Non-goals + +- plugins mutating core schema directly +- arbitrary privileged code execution without explicit permissions + +--- + +## Priority order I would use + +Given the repo state and the transcript, I would sequence it like this: + +**P0** + +1. Cost safety + heartbeat hardening +2. Guided onboarding + first-job magic +3. Shared/cloud deployment foundation +4. Artifact phase 1: non-image attachments + deliverables surfacing + +**P1** 5. Board command surface 6. Visibility/explainability layer 7. Auto mode + interrupt/resume 8. Minimal multi-user collaboration + +**P2** 9. Project workspace / preview / PR lifecycle 10. Plugin system + optional chat plugin 11. Template/preset expansion for startup vs agency vs internal-team onboarding + +Why this order: the current repo is already getting pressure on onboarding failures, auth/onboarding validation, budget enforcement, and wasted token burn. If those are shaky, everything else feels impressive but unsafe. ([GitHub][3]) + +## Bottom line + +The best synthesis is: + +- **Keep** Paperclip as the board-level control plane. +- **Do not** make chat, code review, or workflow-building the core identity. +- **Do** make the product feel conversational, visible, output-oriented, and shared. +- **Do** make coding workflows an integration surface via workspaces/previews/PR links. +- **Use plugins** for richer edges like chat and knowledge. + +That keeps the repo’s current product direction intact while solving almost every pain surfaced in the transcript. + +### Key references + +- README / positioning / roadmap / product boundaries. ([GitHub][1]) +- Product definition. ([GitHub][8]) +- V1 implementation spec and explicit non-goals. ([GitHub][2]) +- Core concepts and architecture. ([GitHub][9]) +- Deployment modes / Tailscale / local-to-cloud path. ([GitHub][5]) +- Developing guide: worktree-local instances, provision hooks, onboarding endpoints. ([GitHub][7]) +- Current issue pressure: onboarding failure, auth/onboarding validation, budget enforcement, circuit breaker, attachment gaps, plugin chat. ([GitHub][3]) + +[1]: https://github.com/paperclipai/paperclip "https://github.com/paperclipai/paperclip" +[2]: https://github.com/paperclipai/paperclip/blob/master/doc/SPEC-implementation.md "https://github.com/paperclipai/paperclip/blob/master/doc/SPEC-implementation.md" +[3]: https://github.com/paperclipai/paperclip/issues/704 "https://github.com/paperclipai/paperclip/issues/704" +[4]: https://github.com/paperclipai/paperclip/blob/master/docs/deploy/tailscale-private-access.md "https://github.com/paperclipai/paperclip/blob/master/docs/deploy/tailscale-private-access.md" +[5]: https://github.com/paperclipai/paperclip/blob/master/docs/deploy/deployment-modes.md "https://github.com/paperclipai/paperclip/blob/master/docs/deploy/deployment-modes.md" +[6]: https://github.com/paperclipai/paperclip/issues/692 "https://github.com/paperclipai/paperclip/issues/692" +[7]: https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md "https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md" +[8]: https://github.com/paperclipai/paperclip/blob/master/doc/PRODUCT.md "https://github.com/paperclipai/paperclip/blob/master/doc/PRODUCT.md" +[9]: https://github.com/paperclipai/paperclip/blob/master/docs/start/core-concepts.md "https://github.com/paperclipai/paperclip/blob/master/docs/start/core-concepts.md" diff --git a/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md b/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md new file mode 100644 index 00000000..68d4ad3c --- /dev/null +++ b/doc/plans/2026-03-13-paperclip-skill-tightening-plan.md @@ -0,0 +1,186 @@ +# Paperclip Skill Tightening Plan + +## Status + +Deferred follow-up. Do not include in the current token-optimization PR beyond documenting the plan. + +## Why This Is Deferred + +The `paperclip` skill is part of the critical control-plane safety surface. Tightening it may reduce fresh-session token use, but it also carries prompt-regression risk. We do not yet have evals that would let us safely prove behavior preservation across assignment handling, checkout rules, comment etiquette, approval workflows, and escalation paths. + +The current PR should ship the lower-risk infrastructure wins first: + +- telemetry normalization +- safe session reuse +- incremental issue/comment context +- bootstrap versus heartbeat prompt separation +- Codex worktree isolation + +## Current Problem + +Fresh runs still spend substantial input tokens even after the context-path fixes. The remaining large startup cost appears to come from loading the full `paperclip` skill and related instruction surface into context at run start. + +The skill currently mixes three kinds of content in one file: + +- hot-path heartbeat procedure used on nearly every run +- critical policy and safety invariants +- rare workflow/reference material that most runs do not need + +That structure is safe but expensive. + +## Goals + +- reduce first-run instruction tokens without weakening agent safety +- preserve all current Paperclip control-plane capabilities +- keep common heartbeat behavior explicit and easy for agents to follow +- move rare workflows and reference material out of the hot path +- create a structure that can later be evaluated systematically + +## Non-Goals + +- changing Paperclip API semantics +- removing required governance rules +- deleting rare workflows +- changing agent defaults in the current PR + +## Recommended Direction + +### 1. Split Hot Path From Lookup Material + +Restructure the skill into: + +- an always-loaded core section for the common heartbeat loop +- on-demand material for infrequent workflows and deep reference + +The core should cover only what is needed on nearly every wake: + +- auth and required headers +- inbox-first assignment retrieval +- mandatory checkout behavior +- `heartbeat-context` first +- incremental comment retrieval rules +- mention/self-assign exception +- blocked-task dedup +- status/comment/release expectations before exit + +### 2. Normalize The Skill Around One Canonical Procedure + +The same rules are currently expressed multiple times across: + +- heartbeat steps +- critical rules +- endpoint reference +- workflow examples + +Refactor so each operational fact has one primary home: + +- procedure +- invariant list +- appendix/reference + +This reduces prompt weight and lowers the chance of internal instruction drift. + +### 3. Compress Prose Into High-Signal Instruction Forms + +Rewrite the hot path using compact operational forms: + +- short ordered checklist +- flat invariant list +- minimal examples only where ambiguity would be risky + +Reduce: + +- narrative explanation +- repeated warnings already covered elsewhere +- large example payloads for common operations +- long endpoint matrices in the main body + +### 4. Move Rare Workflows Behind Explicit Triggers + +These workflows should remain available but should not dominate fresh-run context: + +- OpenClaw invite flow +- project setup flow +- planning `` writeback flow +- instructions-path update flow +- detailed link-formatting examples + +Recommended approach: + +- keep a short pointer in the main skill +- move detailed procedures into sibling skills or referenced docs that agents read only when needed + +### 5. Separate Policy From Reference + +The skill should distinguish: + +- mandatory operating rules +- endpoint lookup/reference +- business-process playbooks + +That separation makes it easier to evaluate prompt changes later and lets adapters or orchestration choose what must always be loaded. + +## Proposed Target Structure + +1. Purpose and authentication +2. Compact heartbeat procedure +3. Hard invariants +4. Required comment/update style +5. Triggered workflow index +6. Appendix/reference + +## Rollout Plan + +### Phase 1. Inventory And Measure + +- annotate the current skill by section and estimate token weight +- identify which sections are truly hot-path versus rare +- capture representative runs to compare before/after prompt size and behavior + +### Phase 2. Structural Refactor Without Semantic Changes + +- rewrite the main skill into the target structure +- preserve all existing rules and capabilities +- move rare workflow details into referenced companion material +- keep wording changes conservative + +### Phase 3. Validate Against Real Scenarios + +Run scenario checks for: + +- normal assigned heartbeat +- comment-triggered wake +- blocked-task dedup behavior +- approval-resolution wake +- delegation/subtask creation +- board handoff back to user +- plan-request handling + +### Phase 4. Decide Default Loading Strategy + +After validation, decide whether: + +- the entire main skill still loads by default, or +- only the compact core loads by default and rare sections are fetched on demand + +Do not change this loading policy without validation. + +## Risks + +- prompt degradation on control-plane safety rules +- agents forgetting rare but important workflows +- accidental removal of repeated wording that was carrying useful behavior +- introducing ambiguous instruction precedence between the core skill and companion materials + +## Preconditions Before Implementation + +- define acceptance scenarios for control-plane correctness +- add at least lightweight eval or scripted scenario coverage for key Paperclip flows +- confirm how adapter/bootstrap layering should load skill content versus references + +## Success Criteria + +- materially lower first-run input tokens for Paperclip-coordinated agents +- no regression in checkout discipline, issue updates, blocked handling, or delegation +- no increase in malformed API usage or ownership mistakes +- agents still complete rare workflows correctly when explicitly asked diff --git a/doc/plans/2026-03-13-plugin-kitchen-sink-example.md b/doc/plans/2026-03-13-plugin-kitchen-sink-example.md new file mode 100644 index 00000000..6a81c5cd --- /dev/null +++ b/doc/plans/2026-03-13-plugin-kitchen-sink-example.md @@ -0,0 +1,699 @@ +# Kitchen Sink Plugin Plan + +## Goal + +Add a new first-party example plugin, `Kitchen Sink (Example)`, that demonstrates every currently implemented Paperclip plugin API surface in one place. + +This plugin is meant to be: + +- a living reference implementation for contributors +- a manual test harness for the plugin runtime +- a discoverable demo of what plugins can actually do today + +It is not meant to be a polished end-user product plugin. + +## Why + +The current plugin system has a real API surface, but it is spread across: + +- SDK docs +- SDK types +- plugin spec prose +- two example plugins that each show only a narrow slice + +That makes it hard to answer basic questions like: + +- what can plugins render? +- what can plugin workers actually do? +- which surfaces are real versus aspirational? +- how should a new plugin be structured in this repo? + +The kitchen-sink plugin should answer those questions by example. + +## Success Criteria + +The plugin is successful if a contributor can install it and, without reading the SDK first, discover and exercise the current plugin runtime surface area from inside Paperclip. + +Concretely: + +- it installs from the bundled examples list +- it exposes at least one demo for every implemented worker API surface +- it exposes at least one demo for every host-mounted UI surface +- it clearly labels local-only / trusted-only demos +- it is safe enough for local development by default +- it doubles as a regression harness for plugin runtime changes + +## Constraints + +- Keep it instance-installed, not company-installed. +- Treat this as a trusted/local example plugin. +- Do not rely on cloud-safe runtime assumptions. +- Avoid destructive defaults. +- Avoid irreversible mutations unless they are clearly labeled and easy to undo. + +## Source Of Truth For This Plan + +This plan is based on the currently implemented SDK/types/runtime, not only the long-horizon spec. + +Primary references: + +- `packages/plugins/sdk/README.md` +- `packages/plugins/sdk/src/types.ts` +- `packages/plugins/sdk/src/ui/types.ts` +- `packages/shared/src/constants.ts` +- `packages/shared/src/types/plugin.ts` + +## Current Surface Inventory + +### Worker/runtime APIs to demonstrate + +These are the concrete `ctx` clients currently exposed by the SDK: + +- `ctx.config` +- `ctx.events` +- `ctx.jobs` +- `ctx.launchers` +- `ctx.http` +- `ctx.secrets` +- `ctx.assets` +- `ctx.activity` +- `ctx.state` +- `ctx.entities` +- `ctx.projects` +- `ctx.companies` +- `ctx.issues` +- `ctx.agents` +- `ctx.goals` +- `ctx.data` +- `ctx.actions` +- `ctx.streams` +- `ctx.tools` +- `ctx.metrics` +- `ctx.logger` + +### UI surfaces to demonstrate + +Surfaces defined in the SDK: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `sidebar` +- `sidebarPanel` +- `detailTab` +- `taskDetailView` +- `projectSidebarItem` +- `toolbarButton` +- `contextMenuItem` +- `commentAnnotation` +- `commentContextMenuItem` + +### Current host confidence + +Confirmed or strongly indicated as mounted in the current app: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `detailTab` +- `projectSidebarItem` +- comment surfaces +- launcher infrastructure + +Need explicit validation before claiming full demo coverage: + +- `sidebar` +- `sidebarPanel` +- `taskDetailView` +- `toolbarButton` as direct slot, distinct from launcher placement +- `contextMenuItem` as direct slot, distinct from comment menu and launcher placement + +The implementation should keep a small validation checklist for these before we call the plugin "complete". + +## Plugin Concept + +The plugin should be named: + +- display name: `Kitchen Sink (Example)` +- package: `@paperclipai/plugin-kitchen-sink-example` +- plugin id: `paperclip.kitchen-sink-example` or `paperclip-kitchen-sink-example` + +Recommendation: use `paperclip-kitchen-sink-example` to match current in-repo example naming style. + +Category mix: + +- `ui` +- `automation` +- `workspace` +- `connector` + +That is intentionally broad because the point is coverage. + +## UX Shape + +The plugin should have one main full-page demo console plus smaller satellites on other surfaces. + +### 1. Plugin page + +Primary route: the plugin `page` surface should be the central dashboard for all demos. + +Recommended page sections: + +- `Overview` + - what this plugin demonstrates + - current capabilities granted + - current host context +- `UI Surfaces` + - links explaining where each other surface should appear +- `Data + Actions` + - buttons and forms for bridge-driven worker demos +- `Events + Streams` + - emit event + - watch event log + - stream demo output +- `Paperclip Domain APIs` + - companies + - projects/workspaces + - issues + - goals + - agents +- `Local Workspace + Process` + - file listing + - file read/write scratch area + - child process demo +- `Jobs + Webhooks + Tools` + - job status + - webhook URL and recent deliveries + - declared tools +- `State + Entities + Assets` + - scoped state editor + - plugin entity inspector + - upload/generated asset demo +- `Observability` + - metrics written + - activity log samples + - latest worker logs + +### 2. Dashboard widget + +A compact widget on the main dashboard should show: + +- plugin health +- count of demos exercised +- recent event/stream activity +- shortcut to the full plugin page + +### 3. Project sidebar item + +Add a `Kitchen Sink` link under each project that deep-links into a project-scoped plugin tab. + +### 4. Detail tabs + +Use detail tabs to demonstrate entity-context rendering on: + +- `project` +- `issue` +- `agent` +- `goal` + +Each tab should show: + +- the host context it received +- the relevant entity fetch via worker bridge +- one small action scoped to that entity + +### 5. Comment surfaces + +Use issue comment demos to prove comment-specific extension points: + +- `commentAnnotation` + - render parsed metadata below each comment + - show comment id, issue id, and a small derived status +- `commentContextMenuItem` + - add a menu action like `Copy Context To Kitchen Sink` + - action writes a plugin entity or state record for later inspection + +### 6. Settings page + +Custom `settingsPage` should be intentionally simple and operational: + +- `About` +- `Danger / Trust Model` +- demo toggles +- local process defaults +- workspace scratch-path behavior +- secret reference inputs +- event/job/webhook sample config + +This plugin should also keep the generic plugin settings `Status` tab useful by writing health, logs, and metrics. + +## Feature Matrix + +Each implemented worker API should have a visible demo. + +### `ctx.config` + +Demo: + +- read live config +- show config JSON +- react to config changes without restart where possible + +### `ctx.events` + +Demos: + +- emit a plugin event +- subscribe to plugin events +- subscribe to a core Paperclip event such as `issue.created` +- show recent received events in a timeline + +### `ctx.jobs` + +Demos: + +- one scheduled heartbeat-style demo job +- one manual run button from the UI if host supports manual job trigger +- show last run result and timestamps + +### `ctx.launchers` + +Demos: + +- declare launchers in manifest +- optionally register one runtime launcher from the worker +- show launcher metadata on the plugin page + +### `ctx.http` + +Demo: + +- make a simple outbound GET request to a safe endpoint +- show status code, latency, and JSON result + +Recommendation: default to a Paperclip-local endpoint or a stable public echo endpoint to avoid flaky docs. + +### `ctx.secrets` + +Demo: + +- operator enters a secret reference in config +- plugin resolves it on demand +- UI only shows masked result length / success status, never raw secret + +### `ctx.assets` + +Demos: + +- generate a text asset from the UI +- optionally upload a tiny JSON blob or screenshot-like text file +- show returned asset URL + +### `ctx.activity` + +Demo: + +- button to write a plugin activity log entry against current company/entity + +### `ctx.state` + +Demos: + +- instance-scoped state +- company-scoped state +- project-scoped state +- issue-scoped state +- delete/reset controls + +Use a small state inspector/editor on the plugin page. + +### `ctx.entities` + +Demos: + +- create plugin-owned sample records +- list/filter them +- show one realistic use case such as "copied comments" or "demo sync records" + +### `ctx.projects` + +Demos: + +- list projects +- list project workspaces +- resolve primary workspace +- resolve workspace for issue + +### `ctx.companies` + +Demo: + +- list companies and show current selected company + +### `ctx.issues` + +Demos: + +- list issues in current company +- create issue +- update issue status/title +- list comments +- create comment + +### `ctx.agents` + +Demos: + +- list agents +- invoke one agent with a test prompt +- pause/resume where safe + +Agent mutation controls should be behind an explicit warning. + +### `ctx.agents.sessions` + +Demos: + +- create agent chat session +- send message +- stream events back to the UI +- close session + +This is a strong candidate for the best "wow" demo on the plugin page. + +### `ctx.goals` + +Demos: + +- list goals +- create goal +- update status/title + +### `ctx.data` + +Use throughout the plugin for all read-side bridge demos. + +### `ctx.actions` + +Use throughout the plugin for all mutation-side bridge demos. + +### `ctx.streams` + +Demos: + +- live event log stream +- token-style stream from an agent session relay +- fake progress stream for a long-running action + +### `ctx.tools` + +Demos: + +- declare 2-3 simple agent tools +- tool 1: echo/diagnostics +- tool 2: project/workspace summary +- tool 3: create issue or write plugin state + +The plugin page should list declared tools and show example input payloads. + +### `ctx.metrics` + +Demo: + +- write a sample metric on each major demo action +- surface a small recent metrics table in the plugin page + +### `ctx.logger` + +Demo: + +- every action logs structured entries +- plugin settings `Status` page then doubles as the log viewer + +## Local Workspace And Process Demos + +The plugin SDK intentionally leaves file/process operations to the plugin itself once it has workspace metadata. + +The kitchen-sink plugin should demonstrate that explicitly. + +### Workspace demos + +- list files from a selected workspace +- read a file +- write to a plugin-owned scratch file +- optionally search files with `rg` if available + +### Process demos + +- run a short-lived command like `pwd`, `ls`, or `git status` +- stream stdout/stderr back to UI +- show exit code and timing + +Important safeguards: + +- default commands must be read-only +- no shell interpolation from arbitrary free-form input in v1 +- provide a curated command list or a strongly validated command form +- clearly label this area as local-only and trusted-only + +## Proposed Manifest Coverage + +The plugin should aim to declare: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `detailTab` for `project`, `issue`, `agent`, `goal` +- `projectSidebarItem` +- `commentAnnotation` +- `commentContextMenuItem` + +Then, after host validation, add if supported: + +- `sidebar` +- `sidebarPanel` +- `taskDetailView` +- `toolbarButton` +- `contextMenuItem` + +It should also declare one or more `ui.launchers` entries to exercise launcher behavior independently of slot rendering. + +## Proposed Package Layout + +New package: + +- `packages/plugins/examples/plugin-kitchen-sink-example/` + +Expected files: + +- `package.json` +- `README.md` +- `tsconfig.json` +- `src/index.ts` +- `src/manifest.ts` +- `src/worker.ts` +- `src/ui/index.tsx` +- `src/ui/components/...` +- `src/ui/hooks/...` +- `src/lib/...` +- optional `scripts/build-ui.mjs` if UI bundling needs esbuild + +## Proposed Internal Architecture + +### Worker modules + +Recommended split: + +- `src/worker.ts` + - plugin definition and wiring +- `src/worker/data.ts` + - `ctx.data.register(...)` +- `src/worker/actions.ts` + - `ctx.actions.register(...)` +- `src/worker/events.ts` + - event subscriptions and event log buffer +- `src/worker/jobs.ts` + - scheduled job handlers +- `src/worker/tools.ts` + - tool declarations and handlers +- `src/worker/local-runtime.ts` + - file/process demos +- `src/worker/demo-store.ts` + - helpers for state/entities/assets/metrics + +### UI modules + +Recommended split: + +- `src/ui/index.tsx` + - exported slot components +- `src/ui/page/KitchenSinkPage.tsx` +- `src/ui/settings/KitchenSinkSettingsPage.tsx` +- `src/ui/widgets/KitchenSinkDashboardWidget.tsx` +- `src/ui/tabs/ProjectKitchenSinkTab.tsx` +- `src/ui/tabs/IssueKitchenSinkTab.tsx` +- `src/ui/tabs/AgentKitchenSinkTab.tsx` +- `src/ui/tabs/GoalKitchenSinkTab.tsx` +- `src/ui/comments/KitchenSinkCommentAnnotation.tsx` +- `src/ui/comments/KitchenSinkCommentMenuItem.tsx` +- `src/ui/shared/...` + +## Configuration Schema + +The plugin should have a substantial but understandable `instanceConfigSchema`. + +Recommended config fields: + +- `enableDangerousDemos` +- `enableWorkspaceDemos` +- `enableProcessDemos` +- `showSidebarEntry` +- `showSidebarPanel` +- `showProjectSidebarItem` +- `showCommentAnnotation` +- `showCommentContextMenuItem` +- `showToolbarLauncher` +- `defaultDemoCompanyId` optional +- `secretRefExample` +- `httpDemoUrl` +- `processAllowedCommands` +- `workspaceScratchSubdir` + +Defaults should keep risky behavior off. + +## Safety Defaults + +Default posture: + +- UI and read-only demos on +- mutating domain demos on but explicitly labeled +- process demos off by default +- no arbitrary shell input by default +- no raw secret rendering ever + +## Phased Build Plan + +### Phase 1: Core plugin skeleton + +- scaffold package +- add manifest, worker, UI entrypoints +- add README +- make it appear in bundled examples list + +### Phase 2: Core, confirmed UI surfaces + +- plugin page +- settings page +- dashboard widget +- project sidebar item +- detail tabs + +### Phase 3: Core worker APIs + +- config +- state +- entities +- companies/projects/issues/goals +- data/actions +- metrics/logger/activity + +### Phase 4: Real-time and automation APIs + +- streams +- events +- jobs +- webhooks +- agent sessions +- tools + +### Phase 5: Local trusted runtime demos + +- workspace file demos +- child process demos +- guarded by config + +### Phase 6: Secondary UI surfaces + +- comment annotation +- comment context menu item +- launchers + +### Phase 7: Validation-only surfaces + +Validate whether the current host truly mounts: + +- `sidebar` +- `sidebarPanel` +- `taskDetailView` +- direct-slot `toolbarButton` +- direct-slot `contextMenuItem` + +If mounted, add demos. +If not mounted, document them as SDK-defined but host-pending. + +## Documentation Deliverables + +The plugin should ship with a README that includes: + +- what it demonstrates +- which surfaces are local-only +- how to install it +- where each UI surface should appear +- a mapping from demo card to SDK API + +It should also be referenced from plugin docs as the "reference everything plugin". + +## Testing And Verification + +Minimum verification: + +- package typecheck/build +- install from bundled example list +- page loads +- widget appears +- project tab appears +- comment surfaces render +- settings page loads +- key actions succeed + +Recommended manual checklist: + +- create issue from plugin +- create goal from plugin +- emit and receive plugin event +- stream action output +- open agent session and receive streamed reply +- upload an asset +- write plugin activity log +- run a safe local process demo + +## Open Questions + +1. Should the process demo remain curated-command-only in the first pass? + Recommendation: yes. + +2. Should the plugin create throwaway "kitchen sink demo" issues/goals automatically? + Recommendation: no. Make creation explicit. + +3. Should we expose unsupported-but-typed surfaces in the UI even if host mounting is not wired? + Recommendation: yes, but label them as `SDK-defined / host validation pending`. + +4. Should agent mutation demos include pause/resume by default? + Recommendation: probably yes, but behind a warning block. + +5. Should this plugin be treated as a supported regression harness in CI later? + Recommendation: yes. Long term, this should be the plugin-runtime smoke test package. + +## Recommended Next Step + +If this plan looks right, the next implementation pass should start by building only: + +- package skeleton +- page +- settings page +- dashboard widget +- one project detail tab +- one issue detail tab +- the basic worker/action/data/state/event scaffolding + +That is enough to lock the architecture before filling in every demo surface. diff --git a/doc/plans/2026-03-13-workspace-product-model-and-work-product.md b/doc/plans/2026-03-13-workspace-product-model-and-work-product.md new file mode 100644 index 00000000..ae5b8e79 --- /dev/null +++ b/doc/plans/2026-03-13-workspace-product-model-and-work-product.md @@ -0,0 +1,1126 @@ +# Workspace Product Model, Work Product, and PR Flow + +## Context + +Paperclip needs to support two very different but equally valid ways of working: + +- a solo developer working directly on `master`, or in a folder that is not even a git repo +- a larger engineering workflow with isolated branches, previews, pull requests, and cleanup automation + +Today, Paperclip already has the beginnings of this model: + +- `projects` can carry execution workspace policy +- `project_workspaces` already exist as a durable project-scoped object +- issues can carry execution workspace settings +- runtime services can be attached to a workspace or issue + +What is missing is a clear product model and UI that make these capabilities understandable and operable. + +The main product risk is overloading one concept to do too much: + +- making subissues do the job of branches or PRs +- making projects too infrastructure-heavy +- making workspaces so hidden that users cannot form a mental model +- making Paperclip feel like a code review tool instead of a control plane + +## Goals + +1. Keep `project` lightweight enough to remain a planning container. +2. Make workspace behavior understandable for both git and non-git projects. +3. Support three real workflows without forcing one: + - shared workspace / direct-edit workflows + - isolated issue workspace workflows + - long-lived branch or operator integration workflows +4. Provide a first-class place to see the outputs of work: + - previews + - PRs + - branches + - commits + - documents and artifacts +5. Keep the main navigation and task board simple. +6. Seamlessly upgrade existing Paperclip users to the new model without forcing disruptive reconfiguration. +7. Support cloud-hosted Paperclip deployments where execution happens in remote or adapter-managed environments rather than local workers. + +## Non-Goals + +- Turning Paperclip into a full code review product +- Requiring every issue to have its own branch or PR +- Requiring every project to configure code/workspace automation +- Making workspaces a top-level global navigation primitive in V1 +- Requiring a local filesystem path or local git checkout to use workspace-aware execution + +## Core Product Decisions + +### 1. Project stays the planning object + +A `project` remains the thing that groups work around a deliverable or initiative. + +It may have: + +- no code at all +- one default codebase/workspace +- several codebases/workspaces + +Projects are not required to become heavyweight. + +### 2. Project workspace is a first-class object, but scoped under project + +A `project workspace` is the durable codebase or root environment for a project. + +Examples: + +- a local folder on disk +- a git repo checkout +- a monorepo package root +- a non-git design/doc folder +- a remote adapter-managed codebase reference + +This is the stable anchor that operators configure once. + +It should not be a top-level sidebar item in the main app. It should live under the project experience. + +### 3. Execution workspace is a first-class runtime object + +An `execution workspace` is where a specific run or issue actually executes. + +Examples: + +- the shared project workspace itself +- an isolated git worktree +- a long-lived operator branch checkout +- an adapter-managed remote sandbox +- a cloud agent provider's isolated branch/session environment + +This object must be recorded explicitly so that Paperclip can: + +- show where work happened +- attach previews and runtime services +- link PRs and branches +- decide cleanup behavior +- support reuse across multiple related issues + +### 4. PRs are work product, not the core issue model + +A PR is an output of work, not the planning unit. + +Paperclip should treat PRs as a type of work product linked back to: + +- the issue +- the execution workspace +- optionally the project workspace + +Git-specific automation should live under workspace policy, not under the core issue abstraction. + +### 5. Existing users must upgrade automatically + +Paperclip already has users and existing project/task data. Any new model must preserve continuity. + +The product should default existing installs into a sensible compatibility mode: + +- existing projects without workspace configuration continue to work unchanged +- existing `project_workspaces` become the durable `project workspace` objects +- existing project execution workspace policy is mapped forward rather than discarded +- issues without explicit workspace fields continue to inherit current behavior + +This migration should feel additive, not like a mandatory re-onboarding flow. + +### 6. Cloud-hosted Paperclip must be a first-class deployment mode + +Paperclip cannot assume that it is running on the same machine as the code. + +In cloud deployments, Paperclip may: + +- run on Vercel or another serverless host +- have no long-lived local worker process +- delegate execution to a remote coding agent or provider-managed sandbox +- receive back a branch, PR, preview URL, or artifact from that remote environment + +The model therefore must be portable: + +- `project workspace` may be remote-managed, not local +- `execution workspace` may have no local `cwd` +- `runtime services` may be tracked by provider reference and URL rather than a host process +- work product harvesting must handle externally owned previews and PRs + +### 7. Subissues remain planning and ownership structure + +Subissues are for decomposition and parallel ownership. + +They are not the same thing as: + +- a branch +- a worktree +- a PR +- a preview + +They may correlate with those things, but they should not be overloaded to mean them. + +## Terminology + +Use these terms consistently in product copy: + +- `Project`: planning container +- `Project workspace`: durable configured codebase/root +- `Execution workspace`: actual runtime workspace used for issue execution +- `Isolated issue workspace`: user-facing term for an issue-specific derived workspace +- `Work product`: previews, PRs, branches, commits, artifacts, docs +- `Runtime service`: a process or service Paperclip owns or tracks for a workspace + +Use these terms consistently in migration and deployment messaging: + +- `Compatible mode`: existing behavior preserved without new workspace automation +- `Adapter-managed workspace`: workspace realized by a remote or cloud execution provider + +Avoid teaching users that "workspace" always means "git worktree on my machine". + +## Product Object Model + +## 1. Project + +Existing object. No fundamental change in role. + +### Required behavior + +- can exist without code/workspace configuration +- can have zero or more project workspaces +- can define execution defaults that new issues inherit + +### Proposed fields + +- `id` +- `companyId` +- `name` +- `description` +- `status` +- `goalIds` +- `leadAgentId` +- `targetDate` +- `executionWorkspacePolicy` +- `workspaces[]` +- `primaryWorkspace` + +## 2. Project Workspace + +Durable, configured, project-scoped codebase/root object. + +This should evolve from the current `project_workspaces` table into a more explicit product object. + +### Motivation + +This separates: + +- "what codebase/root does this project use?" + +from: + +- "what temporary execution environment did this issue run in?" + +That keeps the model simple for solo users while still supporting advanced automation. +It also lets cloud-hosted Paperclip deployments point at codebases and remotes without pretending the Paperclip host has direct filesystem access. + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `name` +- `sourceType` + - `local_path` + - `git_repo` + - `remote_managed` + - `non_git_path` +- `cwd` +- `repoUrl` +- `defaultRef` +- `isPrimary` +- `visibility` + - `default` + - `advanced` +- `setupCommand` +- `cleanupCommand` +- `metadata` +- `createdAt` +- `updatedAt` + +### Notes + +- `sourceType=non_git_path` is important so non-git projects are first-class. +- `setupCommand` and `cleanupCommand` should be allowed here for workspace-root bootstrap, even when isolated execution is not used. +- For a monorepo, multiple project workspaces may point at different roots or packages under one repo. +- `sourceType=remote_managed` is important for cloud deployments where the durable codebase is defined by provider/repo metadata rather than a local checkout path. + +## 3. Project Execution Workspace Policy + +Project-level defaults for how issues execute. + +This is the main operator-facing configuration surface. + +### Motivation + +This lets Paperclip support: + +- direct editing in a shared workspace +- isolated workspaces for issue parallelism +- long-lived integration branch workflows +- remote cloud-agent execution that returns a branch or PR + +without forcing every issue or agent to expose low-level runtime configuration. + +### Proposed fields + +- `enabled: boolean` +- `defaultMode` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `adapter_default` +- `allowIssueOverride: boolean` +- `defaultProjectWorkspaceId: uuid | null` +- `workspaceStrategy` + - `type` + - `project_primary` + - `git_worktree` + - `adapter_managed` + - `baseRef` + - `branchTemplate` + - `worktreeParentDir` + - `provisionCommand` + - `teardownCommand` +- `branchPolicy` + - `namingTemplate` + - `allowReuseExisting` + - `preferredOperatorBranch` +- `pullRequestPolicy` + - `mode` + - `disabled` + - `manual` + - `agent_may_open_draft` + - `approval_required_to_open` + - `approval_required_to_mark_ready` + - `baseBranch` + - `titleTemplate` + - `bodyTemplate` +- `runtimePolicy` + - `allowWorkspaceServices` + - `defaultServicesProfile` + - `autoHarvestOwnedUrls` +- `cleanupPolicy` + - `mode` + - `manual` + - `when_issue_terminal` + - `when_pr_closed` + - `retention_window` + - `retentionHours` + - `keepWhilePreviewHealthy` + - `keepWhileOpenPrExists` + +## 4. Issue Workspace Binding + +Issue-level selection of execution behavior. + +This should remain lightweight in the normal case and only surface richer controls when relevant. + +### Motivation + +Not every issue in a code project should create a new derived workspace. + +Examples: + +- a tiny fix can run in the shared workspace +- three related issues may intentionally share one integration branch +- a solo operator may be working directly on `master` + +### Proposed fields on `issues` + +- `projectWorkspaceId: uuid | null` +- `executionWorkspacePreference` + - `inherit` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `reuse_existing` +- `preferredExecutionWorkspaceId: uuid | null` +- `executionWorkspaceSettings` + - keep advanced per-issue override fields here + +### Rules + +- if the project has no workspace automation, these fields may all be null +- if the project has one primary workspace, issue creation should default to it silently +- `reuse_existing` is advanced-only and should target active execution workspaces, not the whole workspace universe +- existing issues without these fields should behave as `inherit` during migration + +## 5. Execution Workspace + +A durable record for a shared or derived runtime workspace. + +This is the missing object that makes cleanup, previews, PRs, and branch reuse tractable. + +### Motivation + +Without an explicit `execution workspace` record, Paperclip has nowhere stable to attach: + +- derived branch/worktree identity +- active preview ownership +- PR linkage +- cleanup state +- "reuse this existing integration branch" behavior +- remote provider session identity + +### Proposed new object + +`execution_workspaces` + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `projectWorkspaceId` +- `sourceIssueId` +- `mode` + - `shared_workspace` + - `isolated_workspace` + - `operator_branch` + - `adapter_managed` +- `strategyType` + - `project_primary` + - `git_worktree` + - `adapter_managed` +- `name` +- `status` + - `active` + - `idle` + - `in_review` + - `archived` + - `cleanup_failed` +- `cwd` +- `repoUrl` +- `baseRef` +- `branchName` +- `providerRef` +- `providerType` + - `local_fs` + - `git_worktree` + - `adapter_managed` + - `cloud_sandbox` +- `derivedFromExecutionWorkspaceId` +- `lastUsedAt` +- `openedAt` +- `closedAt` +- `cleanupEligibleAt` +- `cleanupReason` +- `metadata` +- `createdAt` +- `updatedAt` + +### Notes + +- `sourceIssueId` is the issue that originally caused the workspace to be created, not necessarily the only issue linked to it later. +- multiple issues may link to the same execution workspace in a long-lived branch workflow. +- `cwd` may be null for remote execution workspaces; provider identity and work product links still make the object useful. + +## 6. Issue-to-Execution Workspace Link + +An issue may need to link to one or more execution workspaces over time. + +Examples: + +- an issue begins in a shared workspace and later moves to an isolated one +- a failed attempt is archived and a new workspace is created +- several issues intentionally share one operator branch workspace + +### Proposed object + +`issue_execution_workspaces` + +### Proposed fields + +- `issueId` +- `executionWorkspaceId` +- `relationType` + - `current` + - `historical` + - `preferred` +- `createdAt` +- `updatedAt` + +### UI simplification + +Most issues should only show one current workspace in the main UI. Historical links belong in advanced/history views. + +## 7. Work Product + +User-facing umbrella concept for outputs of work. + +### Motivation + +Paperclip needs a single place to show: + +- "here is the preview" +- "here is the PR" +- "here is the branch" +- "here is the commit" +- "here is the artifact/report/doc" + +without turning issues into a raw dump of adapter details. + +### Proposed new object + +`issue_work_products` + +### Proposed fields + +- `id` +- `companyId` +- `projectId` +- `issueId` +- `executionWorkspaceId` +- `runtimeServiceId` +- `type` + - `preview_url` + - `runtime_service` + - `pull_request` + - `branch` + - `commit` + - `artifact` + - `document` +- `provider` + - `paperclip` + - `github` + - `gitlab` + - `vercel` + - `netlify` + - `custom` +- `externalId` +- `title` +- `url` +- `status` + - `active` + - `ready_for_review` + - `merged` + - `closed` + - `failed` + - `archived` +- `reviewState` + - `none` + - `needs_board_review` + - `approved` + - `changes_requested` +- `isPrimary` +- `healthStatus` + - `unknown` + - `healthy` + - `unhealthy` +- `summary` +- `metadata` +- `createdByRunId` +- `createdAt` +- `updatedAt` + +### Behavior + +- PRs are stored here as `type=pull_request` +- previews are stored here as `type=preview_url` or `runtime_service` +- Paperclip-owned processes should update health/status automatically +- external providers should at least store link, provider, external id, and latest known state +- cloud agents should be able to create work product records without Paperclip owning the execution host + +## Page and UI Model + +## 1. Global Navigation + +Do not add `Workspaces` as a top-level sidebar item in V1. + +### Motivation + +That would make the whole product feel infra-heavy, even for companies that do not use code automation. + +### Global nav remains + +- Dashboard +- Inbox +- Companies +- Agents +- Goals +- Projects +- Issues +- Approvals + +Workspaces and work product should be surfaced through project and issue detail views. + +## 2. Project Detail + +Add a project sub-navigation that keeps planning first and code second. + +### Tabs + +- `Overview` +- `Issues` +- `Code` +- `Activity` + +Optional future: + +- `Outputs` + +### `Overview` tab + +Planning-first summary: + +- project status +- goals +- lead +- issue counts +- top-level progress +- latest major work product summaries + +### `Issues` tab + +- default to top-level issues only +- show parent issue rollups: + - child count + - `x/y` done + - active preview/PR badges +- optional toggle: `Show subissues` + +### `Code` tab + +This is the main workspace configuration and visibility surface. + +#### Section: `Project Workspaces` + +List durable project workspaces for the project. + +Card/list columns: + +- workspace name +- source type +- path or repo +- default ref +- primary/default badge +- active execution workspaces count +- active issue count +- active preview count +- hosting type / provider when remote-managed + +Actions: + +- `Add workspace` +- `Edit` +- `Set default` +- `Archive` + +#### Section: `Execution Defaults` + +Fields: + +- `Enable workspace automation` +- `Default issue execution mode` + - `Shared workspace` + - `Isolated workspace` + - `Operator branch` + - `Adapter default` +- `Default codebase` +- `Allow issue override` + +#### Section: `Provisioning` + +Fields: + +- `Setup command` +- `Cleanup command` +- `Implementation` + - `Shared workspace` + - `Git worktree` + - `Adapter-managed` +- `Base ref` +- `Branch naming template` +- `Derived workspace parent directory` + +Hide git-specific fields when the selected workspace is not git-backed. +Hide local-path-specific fields when the selected workspace is remote-managed. + +#### Section: `Pull Requests` + +Fields: + +- `PR workflow` + - `Disabled` + - `Manual` + - `Agent may open draft PR` + - `Approval required to open PR` + - `Approval required to mark ready` +- `Default base branch` +- `PR title template` +- `PR body template` + +#### Section: `Previews and Runtime` + +Fields: + +- `Allow workspace runtime services` +- `Default services profile` +- `Harvest owned preview URLs` +- `Track external preview URLs` + +#### Section: `Cleanup` + +Fields: + +- `Cleanup mode` + - `Manual` + - `When issue is terminal` + - `When PR closes` + - `After retention window` +- `Retention window` +- `Keep while preview is active` +- `Keep while PR is open` + +## 3. Add Project Workspace Flow + +Entry point: `Project > Code > Add workspace` + +### Form fields + +- `Name` +- `Source type` + - `Local folder` + - `Git repo` + - `Non-git folder` + - `Remote managed` +- `Local path` +- `Repository URL` +- `Remote provider` +- `Remote workspace reference` +- `Default ref` +- `Set as default workspace` +- `Setup command` +- `Cleanup command` + +### Behavior + +- if source type is non-git, hide branch/PR-specific setup +- if source type is git, show ref and optional advanced branch fields +- if source type is remote-managed, show provider/reference fields and hide local-path-only configuration +- for simple solo users, this can be one path field and one save button + +## 4. Issue Create Flow + +Issue creation should stay simple by default. + +### Default behavior + +If the selected project: + +- has no workspace automation: show no workspace UI +- has one default project workspace and default execution mode: inherit silently + +### Show a `Workspace` section only when relevant + +#### Basic fields + +- `Codebase` + - default selected project workspace +- `Execution mode` + - `Project default` + - `Shared workspace` + - `Isolated workspace` + - `Operator branch` + +#### Advanced-only field + +- `Reuse existing execution workspace` + +This dropdown should show only active execution workspaces for the selected project workspace, with labels like: + +- `dotta/integration-branch` +- `PAP-447-add-worktree-support` +- `shared primary workspace` + +### Important rule + +Do not show a picker containing every possible workspace object by default. + +The normal flow should feel like: + +- choose project +- optionally choose codebase +- optionally choose execution mode + +not: + +- choose from a long mixed list of roots, derived worktrees, previews, and branch names + +### Migration rule + +For existing users, issue creation should continue to look the same until a project explicitly enables richer workspace behavior. + +## 5. Issue Detail + +Issue detail should expose workspace and work product clearly, but without becoming a code host UI. + +### Header chips + +Show compact summary chips near the title/status area: + +- `Codebase: Web App` +- `Workspace: Shared` +- `Workspace: PAP-447-add-worktree-support` +- `PR: Open` +- `Preview: Healthy` + +### Tabs + +- `Comments` +- `Subissues` +- `Work Product` +- `Activity` + +### `Work Product` tab + +Sections: + +- `Current workspace` +- `Previews` +- `Pull requests` +- `Branches and commits` +- `Artifacts and documents` + +#### Current workspace panel + +Fields: + +- workspace name +- mode +- branch +- base ref +- last used +- linked issues count +- cleanup status + +Actions: + +- `Open workspace details` +- `Mark in review` +- `Request cleanup` + +#### Pull request cards + +Fields: + +- title +- provider +- status +- review state +- linked branch +- open/ready/merged timestamps + +Actions: + +- `Open PR` +- `Refresh status` +- `Request board review` + +#### Preview cards + +Fields: + +- title +- URL +- provider +- health +- ownership +- updated at + +Actions: + +- `Open preview` +- `Refresh` +- `Archive` + +## 6. Execution Workspace Detail + +This can be reached from a project code tab or an issue work product tab. + +It does not need to be in the main sidebar. + +### Sections + +- identity +- source issue +- linked issues +- branch/ref +- provider/session identity +- active runtime services +- previews +- PRs +- cleanup state +- event/activity history + +### Motivation + +This is where advanced users go when they need to inspect the mechanics. Most users should not need it in normal flow. + +## 7. Inbox Behavior + +Inbox should surface actionable work product events, not every implementation detail. + +### Show inbox items for + +- issue assigned or updated +- PR needs board review +- PR opened or marked ready +- preview unhealthy +- workspace cleanup failed +- runtime service failed +- remote cloud-agent run returned PR or preview that needs review + +### Do not show by default + +- every workspace heartbeat +- every branch update +- every derived workspace creation + +### Display style + +If the inbox item is about a preview or PR, show issue context with it: + +- issue identifier and title +- parent issue if this is a subissue +- workspace name if relevant + +## 8. Issues List and Kanban + +Keep list and board planning-first. + +### Default behavior + +- show top-level issues by default +- show parent rollups for subissues +- do not flatten every child execution detail into the main board + +### Row/card adornments + +For issues with linked work product, show compact badges: + +- `1 PR` +- `2 previews` +- `shared workspace` +- `isolated workspace` + +### Optional advanced filters + +- `Has PR` +- `Has preview` +- `Workspace mode` +- `Codebase` + +## Upgrade and Migration Plan + +## 1. Product-level migration stance + +Migration must be silent-by-default and compatibility-preserving. + +Existing users should not be forced to: + +- create new workspace objects by hand before they can keep working +- re-tag old issues +- learn new workspace concepts before basic issue flows continue to function + +## 2. Existing project migration + +On upgrade: + +- existing `project_workspaces` records are retained and shown as `Project Workspaces` +- the current primary workspace remains the default codebase +- existing project execution workspace policy is mapped into the new `Project Execution Workspace Policy` surface +- projects with no execution workspace policy stay in compatible/shared mode + +## 3. Existing issue migration + +On upgrade: + +- existing issues default to `executionWorkspacePreference=inherit` +- if an issue already has execution workspace settings, map them forward directly +- if an issue has no explicit workspace data, preserve existing behavior and do not force a user-visible choice + +## 4. Existing run/runtime migration + +On upgrade: + +- active or recent runtime services can be backfilled into execution workspace history where feasible +- missing history should not block rollout; forward correctness matters more than perfect historical reconstruction + +## 5. Rollout UX + +Use additive language in the UI: + +- `Code` +- `Workspace automation` +- `Optional` +- `Advanced` + +Avoid migration copy that implies users were previously using the product "wrong". + +## Cloud Deployment Requirements + +## 1. Paperclip host and execution host must be decoupled + +Paperclip may run: + +- locally with direct filesystem access +- in a cloud app host such as Vercel +- in a hybrid setup with external job runners + +The workspace model must work in all three. + +## 2. Remote execution must support first-class work product reporting + +A cloud agent should be able to: + +- resolve a project workspace +- realize an adapter-managed execution workspace remotely +- produce a branch +- open or update a PR +- emit preview URLs +- register artifacts + +without the Paperclip host itself running local git or local preview processes. + +## 3. Local-only assumptions must be optional + +The following must be optional, not required: + +- local `cwd` +- local git CLI +- host-managed worktree directories +- host-owned long-lived preview processes + +## 4. Same product surface, different provider behavior + +The UI should not split into "local mode" and "cloud mode" products. + +Instead: + +- local projects show path/git implementation details +- cloud projects show provider/reference details +- both surface the same high-level objects: + - project workspace + - execution workspace + - work product + - runtime service or preview + +## Behavior Rules + +## 1. Cleanup must not depend on agents remembering `in_review` + +Agents may still use `in_review`, but cleanup behavior must be governed by policy and observed state. + +### Keep an execution workspace alive while any of these are true + +- a linked issue is non-terminal +- a linked PR is open +- a linked preview/runtime service is active +- the workspace is still within retention window + +### Hide instead of deleting aggressively + +Archived or idle workspaces should be hidden from default lists before they are hard-cleaned up. + +## 2. Multiple issues may intentionally share one execution workspace + +This is how Paperclip supports: + +- solo dev on a shared branch +- operator integration branches +- related features batched into one PR + +This is the key reason not to force 1 issue = 1 workspace = 1 PR. + +## 3. Isolated issue workspaces remain opt-in + +Even in a git-heavy project, isolated workspaces should be optional. + +Examples where shared mode is valid: + +- tiny bug fixes +- branchless prototyping +- non-git projects +- single-user local workflows + +## 4. PR policy belongs to git-backed workspace policy + +PR automation decisions should be made at the project/workspace policy layer. + +The issue should only: + +- surface the resulting PR +- route approvals/review requests +- show status and review state + +## 5. Work product is the user-facing unifier + +Previews, PRs, commits, and artifacts should all be discoverable through one consistent issue-level affordance. + +That keeps Paperclip focused on coordination and visibility instead of splitting outputs across many hidden subsystems. + +## Recommended Implementation Order + +## Phase 1: Clarify current objects in UI + +1. Surface `Project > Code` tab +2. Show existing project workspaces there +3. Re-enable project-level execution workspace policy with revised copy +4. Keep issue creation simple with inherited defaults + +## Phase 2: Add explicit execution workspace record + +1. Add `execution_workspaces` +2. Link runs, issues, previews, and PRs to it +3. Add simple execution workspace detail page +4. Make `cwd` optional and ensure provider-managed remote workspaces are supported from day one + +## Phase 3: Add work product model + +1. Add `issue_work_products` +2. Ingest PRs, previews, branches, commits +3. Add issue `Work Product` tab +4. Add inbox items for actionable work product state changes +5. Support remote agent-created PR/preview reporting without local ownership + +## Phase 4: Add advanced reuse and cleanup workflows + +1. Add `reuse existing execution workspace` +2. Add cleanup lifecycle UI +3. Add operator branch workflow shortcuts +4. Add richer external preview harvesting +5. Add migration tooling/backfill where it improves continuity for existing users + +## Why This Model Is Right + +This model keeps the product balanced: + +- simple enough for solo users +- strong enough for real engineering teams +- flexible for non-git projects +- explicit enough to govern PRs and previews + +Most importantly, it keeps the abstractions clean: + +- projects plan the work +- project workspaces define the durable codebases +- execution workspaces define where work ran +- work product defines what came out of the work +- PRs remain outputs, not the core task model + +It also keeps the rollout practical: + +- existing users can upgrade without workflow breakage +- local-first installs stay simple +- cloud-hosted Paperclip deployments remain first-class + +That is a better fit for Paperclip than either extreme: + +- hiding workspace behavior until nobody understands it +- or making the whole app revolve around code-host mechanics diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md new file mode 100644 index 00000000..075156fd --- /dev/null +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -0,0 +1,155 @@ +# Plugin Authoring Guide + +This guide describes the current, implemented way to create a Paperclip plugin in this repo. + +It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now. + +## Current reality + +- Treat plugin workers and plugin UI as trusted code. +- Plugin UI runs as same-origin JavaScript inside the main Paperclip app. +- Worker-side host APIs are capability-gated. +- Plugin UI is not sandboxed by manifest capabilities. +- There is no host-provided shared React component kit for plugins yet. +- `ctx.assets` is not supported in the current runtime. + +## Scaffold a plugin + +Use the scaffold package: + +```bash +pnpm --filter @paperclipai/create-paperclip-plugin build +node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples +``` + +For a plugin that lives outside the Paperclip repo: + +```bash +pnpm --filter @paperclipai/create-paperclip-plugin build +node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \ + --output /absolute/path/to/plugin-repos \ + --sdk-path /absolute/path/to/paperclip/packages/plugins/sdk +``` + +That creates a package with: + +- `src/manifest.ts` +- `src/worker.ts` +- `src/ui/index.tsx` +- `tests/plugin.spec.ts` +- `esbuild.config.mjs` +- `rollup.config.mjs` + +Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`. + +Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first. + +## Recommended local workflow + +From the generated plugin folder: + +```bash +pnpm install +pnpm typecheck +pnpm test +pnpm build +``` + +For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds. + +Example: + +```bash +curl -X POST http://127.0.0.1:3100/api/plugins/install \ + -H "Content-Type: application/json" \ + -d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}' +``` + +## Supported alpha surface + +Worker: + +- config +- events +- jobs +- launchers +- http +- secrets +- activity +- state +- entities +- projects and project workspaces +- companies +- issues and comments +- agents and agent sessions +- goals +- data/actions +- streams +- tools +- metrics +- logger + +UI: + +- `usePluginData` +- `usePluginAction` +- `usePluginStream` +- `usePluginToast` +- `useHostContext` +- typed slot props from `@paperclipai/plugin-sdk/ui` + +Mount surfaces currently wired in the host include: + +- `page` +- `settingsPage` +- `dashboardWidget` +- `sidebar` +- `sidebarPanel` +- `detailTab` +- `taskDetailView` +- `projectSidebarItem` +- `globalToolbarButton` +- `toolbarButton` +- `contextMenuItem` +- `commentAnnotation` +- `commentContextMenuItem` + +## Company routes + +Plugins may declare a `page` slot with `routePath` to own a company route like: + +```text +/:companyPrefix/ +``` + +Rules: + +- `routePath` must be a single lowercase slug +- it cannot collide with reserved host routes +- it cannot duplicate another installed plugin page route + +## Publishing guidance + +- Use npm packages as the deployment artifact. +- Treat repo-local example installs as a development workflow only. +- Prefer keeping plugin UI self-contained inside the package. +- Do not rely on host design-system components or undocumented app internals. +- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry. + +## Verification before handoff + +At minimum: + +```bash +pnpm --filter typecheck +pnpm --filter test +pnpm --filter build +``` + +If you changed host integration too, also run: + +```bash +pnpm -r typecheck +pnpm test:run +pnpm build +``` diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 896f5115..f3ec6473 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -8,6 +8,29 @@ It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be rea This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md). It is the full target architecture for the plugin system that should follow V1. +## Current implementation caveats + +The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec. + +Today, the practical deployment model is: + +- single-tenant +- self-hosted +- single-node or otherwise filesystem-persistent + +Current limitations to keep in mind: + +- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary. +- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly. +- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory. +- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry. +- 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. + +In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution. + ## 1. Scope This spec covers: @@ -212,6 +235,8 @@ Suggested layout: The package install directory and the plugin data directory are separate. +This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work. + ## 8.2 Operator Commands Paperclip should add CLI commands: @@ -237,6 +262,8 @@ The install process is: 7. Start plugin worker and run health/validation. 8. Mark plugin `ready` or `error`. +For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added. + ## 9. Load Order And Precedence Load order must be deterministic. diff --git a/doc/spec/agent-runs.md b/doc/spec/agent-runs.md index 4c172c7b..f0d02275 100644 --- a/doc/spec/agent-runs.md +++ b/doc/spec/agent-runs.md @@ -249,7 +249,7 @@ Runs local `claude` CLI directly. "cwd": "/absolute/or/relative/path", "promptTemplate": "You are agent {{agent.id}} ...", "model": "optional-model-id", - "maxTurnsPerRun": 80, + "maxTurnsPerRun": 300, "dangerouslySkipPermissions": true, "env": {"KEY": "VALUE"}, "extraArgs": [], diff --git a/doc/spec/ui.md b/doc/spec/ui.md index c7779393..c2ffdb7c 100644 --- a/doc/spec/ui.md +++ b/doc/spec/ui.md @@ -114,7 +114,7 @@ No section header — these are always at the top, below the company header. My Issues ``` -- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, stale tasks, budget alerts, failed heartbeats. The number is the total unread/unresolved count. +- **Inbox** — items requiring the board operator's attention. Badge count on the right. Includes: pending approvals, budget alerts, failed heartbeats. The number is the total unread/unresolved count. - **My Issues** — issues created by or assigned to the board operator. ### 3.3 Work Section diff --git a/docs/adapters/claude-local.md b/docs/adapters/claude-local.md index 3b80f288..c6029e0c 100644 --- a/docs/adapters/claude-local.md +++ b/docs/adapters/claude-local.md @@ -20,7 +20,7 @@ The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | -| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat | +| `maxTurnsPerRun` | number | No | Max agentic turns per heartbeat (defaults to `300`) | | `dangerouslySkipPermissions` | boolean | No | Skip permission prompts (dev only) | ## Prompt Templates diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index 60725a49..ad187f75 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -30,6 +30,8 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten. +When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity. + For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: ```sh diff --git a/docs/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index e33b5411..fae0e4b3 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -6,7 +6,7 @@ summary: Guide to building a custom adapter Build a custom adapter to connect Paperclip to any agent runtime. -If you're using Claude Code, the `create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step. +If you're using Claude Code, the `.agents/skills/create-agent-adapter` skill can guide you through the full adapter creation process interactively. Just ask Claude to create a new adapter and it will walk you through each step. ## Package Structure diff --git a/docs/adapters/gemini-local.md b/docs/adapters/gemini-local.md new file mode 100644 index 00000000..51380b05 --- /dev/null +++ b/docs/adapters/gemini-local.md @@ -0,0 +1,45 @@ +--- +title: Gemini Local +summary: Gemini CLI local adapter setup and configuration +--- + +The `gemini_local` adapter runs Google's Gemini CLI locally. It supports session persistence with `--resume`, skills injection, and structured `stream-json` output parsing. + +## Prerequisites + +- Gemini CLI installed (`gemini` command available) +- `GEMINI_API_KEY` or `GOOGLE_API_KEY` set, or local Gemini CLI auth configured + +## Configuration Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `cwd` | string | Yes | Working directory for the agent process (absolute path; created automatically if missing when permissions allow) | +| `model` | string | No | Gemini model to use. Defaults to `auto`. | +| `promptTemplate` | string | No | Prompt used for all runs | +| `instructionsFilePath` | string | No | Markdown instructions file prepended to the prompt | +| `env` | object | No | Environment variables (supports secret refs) | +| `timeoutSec` | number | No | Process timeout (0 = no timeout) | +| `graceSec` | number | No | Grace period before force-kill | +| `yolo` | boolean | No | Pass `--approval-mode yolo` for unattended operation | + +## Session Persistence + +The adapter persists Gemini session IDs between heartbeats. On the next wake, it resumes the existing conversation with `--resume` so the agent retains context. + +Session resume is cwd-aware: if the working directory changed since the last run, a fresh session starts instead. + +If resume fails with an unknown session error, the adapter automatically retries with a fresh session. + +## Skills Injection + +The adapter symlinks Paperclip skills into the Gemini global skills directory (`~/.gemini/skills`). Existing user skills are not overwritten. + +## Environment Test + +Use the "Test Environment" button in the UI to validate the adapter config. It checks: + +- Gemini CLI is installed and accessible +- Working directory is absolute and available (auto-created if missing and permitted) +- API key/auth hints (`GEMINI_API_KEY` or `GOOGLE_API_KEY`) +- A live hello probe (`gemini --output-format json "Respond with hello."`) to verify CLI readiness diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 4237f87f..44b879d7 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,6 +20,7 @@ When a heartbeat fires, Paperclip: |---------|----------|-------------| | [Claude Local](/adapters/claude-local) | `claude_local` | Runs Claude Code CLI locally | | [Codex Local](/adapters/codex-local) | `codex_local` | Runs OpenAI Codex CLI locally | +| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | | OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | @@ -54,7 +55,7 @@ Three registries consume these modules: ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, or `opencode_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local` - **Need to run a script or command?** Use `process` - **Need to call an external service?** Use `http` - **Need something custom?** [Create your own adapter](/adapters/creating-an-adapter) diff --git a/docs/api/issues.md b/docs/api/issues.md index 1318b171..ff4878df 100644 --- a/docs/api/issues.md +++ b/docs/api/issues.md @@ -1,9 +1,9 @@ --- title: Issues -summary: Issue CRUD, checkout/release, comments, and attachments +summary: Issue CRUD, checkout/release, comments, documents, and attachments --- -Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, and file attachments. +Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, keyed text documents, and file attachments. ## List Issues @@ -29,6 +29,12 @@ GET /api/issues/{issueId} Returns the issue with `project`, `goal`, and `ancestors` (parent chain with their projects and goals). +The response also includes: + +- `planDocument`: the full text of the issue document with key `plan`, when present +- `documentSummaries`: metadata for all linked issue documents +- `legacyPlanDocument`: a read-only fallback when the description still contains an old `` block + ## Create Issue ``` @@ -100,6 +106,54 @@ POST /api/issues/{issueId}/comments @-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent. +## Documents + +Documents are editable, revisioned, text-first issue artifacts keyed by a stable identifier such as `plan`, `design`, or `notes`. + +### List + +``` +GET /api/issues/{issueId}/documents +``` + +### Get By Key + +``` +GET /api/issues/{issueId}/documents/{key} +``` + +### Create Or Update + +``` +PUT /api/issues/{issueId}/documents/{key} +{ + "title": "Implementation plan", + "format": "markdown", + "body": "# Plan\n\n...", + "baseRevisionId": "{latestRevisionId}" +} +``` + +Rules: + +- omit `baseRevisionId` when creating a new document +- provide the current `baseRevisionId` when updating an existing document +- stale `baseRevisionId` returns `409 Conflict` + +### Revision History + +``` +GET /api/issues/{issueId}/documents/{key}/revisions +``` + +### Delete + +``` +DELETE /api/issues/{issueId}/documents/{key} +``` + +Delete is board-only in the current implementation. + ## Attachments ### Upload diff --git a/docs/plans/2026-03-13-issue-documents-plan.md b/docs/plans/2026-03-13-issue-documents-plan.md new file mode 100644 index 00000000..c8a5dd1c --- /dev/null +++ b/docs/plans/2026-03-13-issue-documents-plan.md @@ -0,0 +1,569 @@ +# Issue Documents Plan + +Status: Draft +Owner: Backend + UI + Agent Protocol +Date: 2026-03-13 +Primary issue: `PAP-448` + +## Summary + +Add first-class **documents** to Paperclip as editable, revisioned, company-scoped text artifacts that can be linked to issues. + +The first required convention is a document with key `plan`. + +This solves the immediate workflow problem in `PAP-448`: + +- plans should stop living inside issue descriptions as `` blocks +- agents and board users should be able to create/update issue documents directly +- `GET /api/issues/:id` should include the full `plan` document and expose the other available documents +- issue detail should render documents under the description + +This should be built as the **text-document slice** of the broader artifact system, not as a replacement for attachments/assets. + +## Recommended Product Shape + +### Documents vs attachments vs artifacts + +- **Documents**: editable text content with stable keys and revision history. +- **Attachments**: uploaded/generated opaque files backed by storage (`assets` + `issue_attachments`). +- **Artifacts**: later umbrella/read-model that can unify documents, attachments, previews, and workspace files. + +Recommendation: + +- implement **issue documents now** +- keep existing attachments as-is +- defer full artifact unification until there is a second real consumer beyond issue documents + attachments + +This keeps `PAP-448` focused while still fitting the larger artifact direction. + +## Goals + +1. Give issues first-class keyed documents, starting with `plan`. +2. Make documents editable by board users and same-company agents with issue access. +3. Preserve change history with append-only revisions. +4. Make the `plan` document automatically available in the normal issue fetch used by agents/heartbeats. +5. Replace the current ``-in-description convention in skills/docs. +6. Keep the design compatible with a future artifact/deliverables layer. + +## Non-Goals + +- full collaborative doc editing +- binary-file version history +- browser IDE or workspace editor +- full artifact-system implementation in the same change +- generalized polymorphic relations for every entity type on day one + +## Product Decisions + +### 1. Keyed issue documents + +Each issue can have multiple documents. Each document relation has a stable key: + +- `plan` +- `design` +- `notes` +- `report` +- custom keys later + +Key rules: + +- unique per issue, case-insensitive +- normalized to lowercase slug form +- machine-oriented and stable +- title is separate and user-facing + +The `plan` key is conventional and reserved by Paperclip workflow/docs. + +### 2. Text-first v1 + +V1 documents should be text-first, not arbitrary blobs. + +Recommended supported formats: + +- `markdown` +- `plain_text` +- `json` +- `html` + +Recommendation: + +- optimize UI for `markdown` +- allow raw editing for the others +- keep PDFs/images/CSVs/etc as attachments/artifacts, not editable documents + +### 3. Revision model + +Every document update creates a new immutable revision. + +The current document row stores the latest snapshot for fast reads. + +### 4. Concurrency model + +Do not use silent last-write-wins. + +Updates should include `baseRevisionId`: + +- create: no base revision required +- update: `baseRevisionId` must match current latest revision +- mismatch: return `409 Conflict` + +This is important because both board users and agents may edit the same document. + +### 5. Issue fetch behavior + +`GET /api/issues/:id` should include: + +- full `planDocument` when a `plan` document exists +- `documentSummaries` for all linked documents + +It should not inline every document body by default. + +This keeps issue fetches useful for agents without making every issue payload unbounded. + +### 6. Legacy `` compatibility + +If an issue has no `plan` document but its description contains a legacy `` block: + +- expose that as a legacy read-only fallback in API/UI +- mark it as legacy/synthetic +- prefer a real `plan` document when both exist + +Recommendation: + +- do not auto-rewrite old issue descriptions in the first rollout +- provide an explicit import/migrate path later + +## Proposed Data Model + +Recommendation: make documents first-class, but keep issue linkage explicit via a join table. + +This preserves foreign keys today and gives a clean path to future `project_documents` or `company_documents` tables later. + +## Tables + +### `documents` + +Canonical text document record. + +Suggested columns: + +- `id` +- `company_id` +- `title` +- `format` +- `latest_body` +- `latest_revision_id` +- `latest_revision_number` +- `created_by_agent_id` +- `created_by_user_id` +- `updated_by_agent_id` +- `updated_by_user_id` +- `created_at` +- `updated_at` + +### `document_revisions` + +Append-only history. + +Suggested columns: + +- `id` +- `company_id` +- `document_id` +- `revision_number` +- `body` +- `change_summary` +- `created_by_agent_id` +- `created_by_user_id` +- `created_at` + +Constraints: + +- unique `(document_id, revision_number)` + +### `issue_documents` + +Issue relation + workflow key. + +Suggested columns: + +- `id` +- `company_id` +- `issue_id` +- `document_id` +- `key` +- `created_at` +- `updated_at` + +Constraints: + +- unique `(company_id, issue_id, key)` +- unique `(document_id)` to keep one issue relation per document in v1 + +## Why not use `assets` for this? + +Because `assets` solves blob storage, not: + +- stable keyed semantics like `plan` +- inline text editing +- revision history +- optimistic concurrency +- cheap inclusion in `GET /issues/:id` + +Documents and attachments should remain separate primitives, then meet later in a deliverables/artifact read-model. + +## Shared Types and API Contract + +## New shared types + +Add: + +- `DocumentFormat` +- `IssueDocument` +- `IssueDocumentSummary` +- `DocumentRevision` + +Recommended `IssueDocument` shape: + +```ts +type DocumentFormat = "markdown" | "plain_text" | "json" | "html"; + +interface IssueDocument { + id: string; + companyId: string; + issueId: string; + key: string; + title: string | null; + format: DocumentFormat; + body: string; + latestRevisionId: string; + latestRevisionNumber: number; + createdByAgentId: string | null; + createdByUserId: string | null; + updatedByAgentId: string | null; + updatedByUserId: string | null; + createdAt: Date; + updatedAt: Date; +} +``` + +Recommended `IssueDocumentSummary` shape: + +```ts +interface IssueDocumentSummary { + id: string; + key: string; + title: string | null; + format: DocumentFormat; + latestRevisionId: string; + latestRevisionNumber: number; + updatedAt: Date; +} +``` + +## Issue type enrichment + +Extend `Issue` with: + +```ts +interface Issue { + ... + planDocument?: IssueDocument | null; + documentSummaries?: IssueDocumentSummary[]; + legacyPlanDocument?: { + key: "plan"; + body: string; + source: "issue_description"; + } | null; +} +``` + +This directly satisfies the `PAP-448` requirement for heartbeat/API issue fetches. + +## API endpoints + +Recommended endpoints: + +- `GET /api/issues/:issueId/documents` +- `GET /api/issues/:issueId/documents/:key` +- `PUT /api/issues/:issueId/documents/:key` +- `GET /api/issues/:issueId/documents/:key/revisions` +- `DELETE /api/issues/:issueId/documents/:key` optionally board-only in v1 + +Recommended `PUT` body: + +```ts +{ + title?: string | null; + format: "markdown" | "plain_text" | "json" | "html"; + body: string; + changeSummary?: string | null; + baseRevisionId?: string | null; +} +``` + +Behavior: + +- missing document + no `baseRevisionId`: create +- existing document + matching `baseRevisionId`: update +- existing document + stale `baseRevisionId`: `409` + +## Authorization and invariants + +- all document records are company-scoped +- issue relation must belong to same company +- board access follows existing issue access rules +- agent access follows existing same-company issue access rules +- every mutation writes activity log entries + +Recommended delete rule for v1: + +- board can delete documents +- agents can create/update, but not delete + +That keeps automated systems from removing canonical docs too easily. + +## UI Plan + +## Issue detail + +Add a new **Documents** section directly under the issue description. + +Recommended behavior: + +- show `plan` first when present +- show other documents below it +- render a gist-like header: + - key + - title + - last updated metadata + - revision number +- support inline edit +- support create new document by key +- support revision history drawer or sheet + +Recommended presentation order: + +1. Description +2. Documents +3. Attachments +4. Comments / activity / sub-issues + +This matches the request that documents live under the description while still leaving attachments available. + +## Editing UX + +Recommendation: + +- use markdown preview + raw edit toggle for markdown docs +- use raw textarea editor for non-markdown docs in v1 +- show explicit save conflicts on `409` +- show a clear empty state: "No documents yet" + +## Legacy plan rendering + +If there is no stored `plan` document but legacy `` exists: + +- show it in the Documents section +- mark it `Legacy plan from description` +- offer create/import in a later pass + +## Agent Protocol and Skills + +Update the Paperclip agent workflow so planning no longer edits the issue description. + +Required changes: + +- update `skills/paperclip/SKILL.md` +- replace the `` instructions with document creation/update instructions +- document the new endpoints in `docs/api/issues.md` +- update any internal planning docs that still teach inline `` blocks + +New rule: + +- when asked to make a plan for an issue, create or update the issue document with key `plan` +- leave a comment that the plan document was created/updated +- do not mark the issue done + +## Relationship to the Artifact Plan + +This work should explicitly feed the broader artifact/deliverables direction. + +Recommendation: + +- keep documents as their own primitive in this change +- add `document` to any future `ArtifactKind` +- later build a deliverables read-model that aggregates: + - issue documents + - issue attachments + - preview URLs + - workspace-file references + +The artifact proposal currently has no explicit `document` kind. It should. + +Recommended future shape: + +```ts +type ArtifactKind = + | "document" + | "attachment" + | "workspace_file" + | "preview" + | "report_link"; +``` + +## Implementation Phases + +## Phase 1: Shared contract and schema + +Files: + +- `packages/db/src/schema/documents.ts` +- `packages/db/src/schema/document_revisions.ts` +- `packages/db/src/schema/issue_documents.ts` +- `packages/db/src/schema/index.ts` +- `packages/db/src/migrations/*` +- `packages/shared/src/types/issue.ts` +- `packages/shared/src/validators/issue.ts` or new document validator file +- `packages/shared/src/index.ts` + +Acceptance: + +- schema enforces one key per issue +- revisions are append-only +- shared types expose plan/document fields on issue fetch + +## Phase 2: Server services and routes + +Files: + +- `server/src/services/issues.ts` or `server/src/services/documents.ts` +- `server/src/routes/issues.ts` +- `server/src/services/activity.ts` callsites + +Behavior: + +- list/get/upsert/delete documents +- revision listing +- `GET /issues/:id` returns `planDocument` + `documentSummaries` +- company boundary checks match issue routes + +Acceptance: + +- agents and board can fetch/update same-company issue documents +- stale edits return `409` +- activity timeline shows document changes + +## Phase 3: UI issue documents surface + +Files: + +- `ui/src/api/issues.ts` +- `ui/src/lib/queryKeys.ts` +- `ui/src/pages/IssueDetail.tsx` +- new reusable document UI component if needed + +Behavior: + +- render plan + documents under description +- create/update by key +- open revision history +- show conflicts/errors clearly + +Acceptance: + +- board can create a `plan` doc from issue detail +- updated plan appears immediately +- issue detail no longer depends on description-embedded `` + +## Phase 4: Skills/docs migration + +Files: + +- `skills/paperclip/SKILL.md` +- `docs/api/issues.md` +- `doc/SPEC-implementation.md` +- relevant plan/docs that mention `` + +Acceptance: + +- planning guidance references issue documents, not inline issue description tags +- API docs describe the new document endpoints and issue payload additions + +## Phase 5: Legacy compatibility and follow-up + +Behavior: + +- read legacy `` blocks as fallback +- optionally add explicit import/migration command later + +Follow-up, not required for first merge: + +- deliverables/artifact read-model +- project/company documents +- comment-linked documents +- diff view between revisions + +## Test Plan + +### Server + +- document create/read/update/delete lifecycle +- revision numbering +- `baseRevisionId` conflict handling +- company boundary enforcement +- agent vs board authorization +- issue fetch includes `planDocument` and document summaries +- legacy `` fallback behavior +- activity log mutation coverage + +### UI + +- issue detail shows plan document +- create/update flows invalidate queries correctly +- conflict and validation errors are surfaced +- legacy plan fallback renders correctly + +### Verification + +Run before implementation is declared complete: + +```sh +pnpm -r typecheck +pnpm test:run +pnpm build +``` + +## Open Questions + +1. Should v1 documents be markdown-only, with `json/html/plain_text` deferred? + Recommendation: allow all four in API, optimize UI for markdown only. + +2. Should agents be allowed to create arbitrary keys, or only conventional keys? + Recommendation: allow arbitrary keys with normalized validation; reserve `plan` as special behavior only. + +3. Should delete exist in v1? + Recommendation: yes, but board-only. + +4. Should legacy `` blocks ever be auto-migrated? + Recommendation: no automatic mutation in the first rollout. + +5. Should documents appear inside a future Deliverables section or remain a top-level Issue section? + Recommendation: keep a dedicated Documents section now; later also expose them in Deliverables if an aggregated artifact view is added. + +## Final Recommendation + +Ship **issue documents** as a focused, text-first primitive now. + +Do not try to solve full artifact unification in the same implementation. + +Use: + +- first-class document tables +- issue-level keyed linkage +- append-only revisions +- `planDocument` embedded in normal issue fetches +- legacy `` fallback +- skill/docs migration away from description-embedded plans + +This addresses the real planning workflow problem immediately and leaves the artifact system room to grow cleanly afterward. diff --git a/packages/adapter-utils/CHANGELOG.md b/packages/adapter-utils/CHANGELOG.md index dd4c015b..76cabbd7 100644 --- a/packages/adapter-utils/CHANGELOG.md +++ b/packages/adapter-utils/CHANGELOG.md @@ -1,5 +1,11 @@ # @paperclipai/adapter-utils +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapter-utils/package.json b/packages/adapter-utils/package.json index 4b264bf4..3a908ee5 100644 --- a/packages/adapter-utils/package.json +++ b/packages/adapter-utils/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-utils", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index 89f03fb4..56579022 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -22,3 +22,9 @@ export type { CLIAdapterModule, CreateConfigValues, } from "./types.js"; +export { + REDACTED_HOME_PATH_USER, + redactHomePathUserSegments, + redactHomePathUserSegmentsInValue, + redactTranscriptEntryPaths, +} from "./log-redaction.js"; diff --git a/packages/adapter-utils/src/log-redaction.ts b/packages/adapter-utils/src/log-redaction.ts new file mode 100644 index 00000000..037e279e --- /dev/null +++ b/packages/adapter-utils/src/log-redaction.ts @@ -0,0 +1,81 @@ +import type { TranscriptEntry } from "./types.js"; + +export const REDACTED_HOME_PATH_USER = "[]"; + +const HOME_PATH_PATTERNS = [ + { + regex: /\/Users\/[^/\\\s]+/g, + replace: `/Users/${REDACTED_HOME_PATH_USER}`, + }, + { + regex: /\/home\/[^/\\\s]+/g, + replace: `/home/${REDACTED_HOME_PATH_USER}`, + }, + { + regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g, + replace: `$1${REDACTED_HOME_PATH_USER}`, + }, +] as const; + +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +export function redactHomePathUserSegments(text: string): string { + let result = text; + for (const pattern of HOME_PATH_PATTERNS) { + result = result.replace(pattern.regex, pattern.replace); + } + return result; +} + +export function redactHomePathUserSegmentsInValue(value: T): T { + if (typeof value === "string") { + return redactHomePathUserSegments(value) as T; + } + if (Array.isArray(value)) { + return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T; + } + if (!isPlainObject(value)) { + return value; + } + + const redacted: Record = {}; + for (const [key, entry] of Object.entries(value)) { + redacted[key] = redactHomePathUserSegmentsInValue(entry); + } + return redacted as T; +} + +export function redactTranscriptEntryPaths(entry: TranscriptEntry): TranscriptEntry { + switch (entry.kind) { + case "assistant": + case "thinking": + case "user": + case "stderr": + case "system": + case "stdout": + return { ...entry, text: redactHomePathUserSegments(entry.text) }; + case "tool_call": + return { ...entry, name: redactHomePathUserSegments(entry.name), input: redactHomePathUserSegmentsInValue(entry.input) }; + case "tool_result": + return { ...entry, content: redactHomePathUserSegments(entry.content) }; + case "init": + return { + ...entry, + model: redactHomePathUserSegments(entry.model), + sessionId: redactHomePathUserSegments(entry.sessionId), + }; + case "result": + return { + ...entry, + text: redactHomePathUserSegments(entry.text), + subtype: redactHomePathUserSegments(entry.subtype), + errors: entry.errors.map((error) => redactHomePathUserSegments(error)), + }; + default: + return entry; + } +} diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 2b9de31f..52e52b4c 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -32,6 +32,23 @@ export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i; +const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ + "../../skills", + "../../../../../skills", +]; + +export interface PaperclipSkillEntry { + name: string; + source: string; +} + +function normalizePathSlashes(value: string): string { + return value.replaceAll("\\", "/"); +} + +function isMaintainerOnlySkillTarget(candidate: string): boolean { + return normalizePathSlashes(candidate).includes("/.agents/skills/"); +} export function parseObject(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { @@ -95,6 +112,16 @@ export function renderTemplate(template: string, data: Record) return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path)); } +export function joinPromptSections( + sections: Array, + separator = "\n\n", +) { + return sections + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + .join(separator); +} + export function redactEnvForLogs(env: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { @@ -245,6 +272,136 @@ export async function ensureAbsoluteDirectory( } } +export async function resolvePaperclipSkillsDir( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const candidates = [ + ...PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)), + ...additionalCandidates.map((candidate) => path.resolve(candidate)), + ]; + const seenRoots = new Set(); + + for (const root of candidates) { + if (seenRoots.has(root)) continue; + seenRoots.add(root); + const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false); + if (isDirectory) return root; + } + + return null; +} + +export async function listPaperclipSkillEntries( + moduleDir: string, + additionalCandidates: string[] = [], +): Promise { + const root = await resolvePaperclipSkillsDir(moduleDir, additionalCandidates); + if (!root) return []; + + try { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + source: path.join(root, entry.name), + })); + } catch { + return []; + } +} + +export async function readPaperclipSkillMarkdown( + moduleDir: string, + skillName: string, +): Promise { + const normalized = skillName.trim().toLowerCase(); + if (!normalized) return null; + + const entries = await listPaperclipSkillEntries(moduleDir); + const match = entries.find((entry) => entry.name === normalized); + if (!match) return null; + + try { + return await fs.readFile(path.join(match.source, "SKILL.md"), "utf8"); + } catch { + return null; + } +} + +export async function ensurePaperclipSkillSymlink( + source: string, + target: string, + linkSkill: (source: string, target: string) => Promise = (linkSource, linkTarget) => + fs.symlink(linkSource, linkTarget), +): Promise<"created" | "repaired" | "skipped"> { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await linkSkill(source, target); + return "created"; + } + + if (!existing.isSymbolicLink()) { + return "skipped"; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return "skipped"; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === source) { + return "skipped"; + } + + const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false); + if (linkedPathExists) { + return "skipped"; + } + + await fs.unlink(target); + await linkSkill(source, target); + return "repaired"; +} + +export async function removeMaintainerOnlySkillSymlinks( + skillsHome: string, + allowedSkillNames: Iterable, +): Promise { + const allowed = new Set(Array.from(allowedSkillNames)); + try { + const entries = await fs.readdir(skillsHome, { withFileTypes: true }); + const removed: string[] = []; + for (const entry of entries) { + if (allowed.has(entry.name)) continue; + + const target = path.join(skillsHome, entry.name); + const existing = await fs.lstat(target).catch(() => null); + if (!existing?.isSymbolicLink()) continue; + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) continue; + + const resolvedLinkedPath = path.isAbsolute(linkedPath) + ? linkedPath + : path.resolve(path.dirname(target), linkedPath); + if ( + !isMaintainerOnlySkillTarget(linkedPath) && + !isMaintainerOnlySkillTarget(resolvedLinkedPath) + ) { + continue; + } + + await fs.unlink(target); + removed.push(entry.name); + } + + return removed; + } catch { + return []; + } +} + export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) { const resolved = await resolveCommandPath(command, cwd, env); if (resolved) return; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 8eb01190..df0d075a 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -75,6 +75,14 @@ export interface AdapterExecutionResult { runtimeServices?: AdapterRuntimeServiceReport[]; summary?: string | null; clearSession?: boolean; + question?: { + prompt: string; + choices: Array<{ + key: string; + label: string; + description?: string; + }>; + } | null; } export interface AdapterSessionCodec { @@ -91,6 +99,7 @@ export interface AdapterInvocationMeta { commandNotes?: string[]; env?: Record; prompt?: string; + promptMetrics?: Record; context?: Record; } @@ -189,7 +198,7 @@ export type TranscriptEntry = | { kind: "assistant"; ts: string; text: string; delta?: boolean } | { kind: "thinking"; ts: string; text: string; delta?: boolean } | { kind: "user"; ts: string; text: string } - | { kind: "tool_call"; ts: string; name: string; input: unknown } + | { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string } | { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean } | { kind: "init"; ts: string; model: string; sessionId: string } | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } diff --git a/packages/adapters/claude-local/CHANGELOG.md b/packages/adapters/claude-local/CHANGELOG.md index ac3bcac5..b9035585 100644 --- a/packages/adapters/claude-local/CHANGELOG.md +++ b/packages/adapters/claude-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-claude-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/claude-local/package.json b/packages/adapters/claude-local/package.json index f73390b7..35a6d9ed 100644 --- a/packages/adapters/claude-local/package.json +++ b/packages/adapters/claude-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-claude-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index be85439d..dfcd1173 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -12,6 +12,7 @@ import { parseObject, parseJson, buildPaperclipEnv, + joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, @@ -121,6 +122,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise => typeof value === "object" && value !== null, @@ -215,6 +217,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } @@ -363,7 +368,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildClaudeArgs = (resumeSessionId: string | null) => { const args = ["--print", "-", "--output-format", "stream-json", "--verbose"]; @@ -416,6 +439,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? value.trim() : null; +} + +export async function pathExists(candidate: string): Promise { + return fs.access(candidate).then(() => true).catch(() => false); +} + +export function resolveCodexHomeDir(env: NodeJS.ProcessEnv = process.env): string { + const fromEnv = nonEmpty(env.CODEX_HOME); + if (fromEnv) return path.resolve(fromEnv); + return path.join(os.homedir(), ".codex"); +} + +function isWorktreeMode(env: NodeJS.ProcessEnv): boolean { + return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? ""); +} + +function resolveWorktreeCodexHomeDir(env: NodeJS.ProcessEnv): string | null { + if (!isWorktreeMode(env)) return null; + const paperclipHome = nonEmpty(env.PAPERCLIP_HOME); + if (!paperclipHome) return null; + const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID); + if (instanceId) { + return path.resolve(paperclipHome, "instances", instanceId, "codex-home"); + } + return path.resolve(paperclipHome, "codex-home"); +} + +async function ensureParentDir(target: string): Promise { + await fs.mkdir(path.dirname(target), { recursive: true }); +} + +async function ensureSymlink(target: string, source: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (!existing) { + await ensureParentDir(target); + await fs.symlink(source, target); + return; + } + + if (!existing.isSymbolicLink()) { + return; + } + + const linkedPath = await fs.readlink(target).catch(() => null); + if (!linkedPath) return; + + const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); + if (resolvedLinkedPath === source) return; + + await fs.unlink(target); + await fs.symlink(source, target); +} + +async function ensureCopiedFile(target: string, source: string): Promise { + const existing = await fs.lstat(target).catch(() => null); + if (existing) return; + await ensureParentDir(target); + await fs.copyFile(source, target); +} + +export async function prepareWorktreeCodexHome( + env: NodeJS.ProcessEnv, + onLog: AdapterExecutionContext["onLog"], +): Promise { + const targetHome = resolveWorktreeCodexHomeDir(env); + if (!targetHome) return null; + + const sourceHome = resolveCodexHomeDir(env); + if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome; + + await fs.mkdir(targetHome, { recursive: true }); + + for (const name of SYMLINKED_SHARED_FILES) { + const source = path.join(sourceHome, name); + if (!(await pathExists(source))) continue; + await ensureSymlink(path.join(targetHome, name), source); + } + + for (const name of COPIED_SHARED_FILES) { + const source = path.join(sourceHome, name); + if (!(await pathExists(source))) continue; + await ensureCopiedFile(path.join(targetHome, name), source); + } + + await onLog( + "stderr", + `[paperclip] Using worktree-isolated Codex home "${targetHome}" (seeded from "${sourceHome}").\n`, + ); + return targetHome; +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 3dec4ff7..e1718cc1 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; @@ -13,17 +12,18 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, + joinPromptSections, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; +import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), // published: /dist/server/ -> /skills/ - path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/ -]; const CODEX_ROLLOUT_NOISE_RE = /^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i; @@ -61,39 +61,95 @@ function resolveCodexBillingType(env: Record): "api" | "subscrip return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription"; } -function codexHomeDir(): string { - const fromEnv = process.env.CODEX_HOME; - if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim(); - return path.join(os.homedir(), ".codex"); +async function isLikelyPaperclipRepoRoot(candidate: string): Promise { + const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([ + pathExists(path.join(candidate, "pnpm-workspace.yaml")), + pathExists(path.join(candidate, "package.json")), + pathExists(path.join(candidate, "server")), + pathExists(path.join(candidate, "packages", "adapter-utils")), + ]); + + return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir; } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; +async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise { + if (path.basename(candidate) !== skillName) return false; + const skillsRoot = path.dirname(candidate); + if (path.basename(skillsRoot) !== "skills") return false; + if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false; + + let cursor = path.dirname(skillsRoot); + for (let depth = 0; depth < 6; depth += 1) { + if (await isLikelyPaperclipRepoRoot(cursor)) return true; + const parent = path.dirname(cursor); + if (parent === cursor) break; + cursor = parent; } - return null; + + return false; } -async function ensureCodexSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; +type EnsureCodexSkillsInjectedOptions = { + skillsHome?: string; + skillsEntries?: Awaited>; + linkSkill?: (source: string, target: string) => Promise; +}; - const skillsHome = path.join(codexHomeDir(), "skills"); +export async function ensureCodexSkillsInjected( + onLog: AdapterExecutionContext["onLog"], + options: EnsureCodexSkillsInjectedOptions = {}, +) { + const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; + + const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills"); await fs.mkdir(skillsHome, { recursive: true }); - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`, + ); + } + const linkSkill = options.linkSkill; + for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; try { - await fs.symlink(source, target); + const existing = await fs.lstat(target).catch(() => null); + if (existing?.isSymbolicLink()) { + const linkedPath = await fs.readlink(target).catch(() => null); + const resolvedLinkedPath = linkedPath + ? path.resolve(path.dirname(target), linkedPath) + : null; + if ( + resolvedLinkedPath && + resolvedLinkedPath !== entry.source && + (await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name)) + ) { + await fs.unlink(target); + if (linkSkill) { + await linkSkill(entry.source, target); + } else { + await fs.symlink(entry.source, target); + } + await onLog( + "stderr", + `[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`, + ); + continue; + } + } + + const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); + if (result === "skipped") continue; + await onLog( "stderr", - `[paperclip] Injected Codex skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`, ); } catch (err) { await onLog( @@ -132,6 +188,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, @@ -152,12 +209,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); - await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); - await ensureCodexSkillsInjected(onLog); const envConfig = parseObject(config.env); + const configuredCodexHome = + typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0 + ? path.resolve(envConfig.CODEX_HOME.trim()) + : null; + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + const preparedWorktreeCodexHome = + configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog); + const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome; + await ensureCodexSkillsInjected( + onLog, + effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {}, + ); const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; const env: Record = { ...buildPaperclipEnv(agent) }; + if (effectiveCodexHome) { + env.CODEX_HOME = effectiveCodexHome; + } env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || @@ -224,6 +294,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } @@ -270,6 +343,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildArgs = (resumeSessionId: string | null) => { const args = ["exec", "--json"]; @@ -338,6 +432,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise, ts: string): Transcr .filter((change): change is Record => Boolean(change)) .map((change) => { const kind = asString(change.kind, "update"); - const path = asString(change.path, "unknown"); + const path = redactHomePathUserSegments(asString(change.path, "unknown")); return `${kind} ${path}`; }); @@ -125,13 +131,13 @@ function parseCodexItem( if (itemType === "agent_message") { const text = asString(item.text); - if (text) return [{ kind: "assistant", ts, text }]; + if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }]; return []; } if (itemType === "reasoning") { const text = asString(item.text); - if (text) return [{ kind: "thinking", ts, text }]; + if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }]; return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }]; } @@ -147,8 +153,9 @@ function parseCodexItem( return [{ kind: "tool_call", ts, - name: asString(item.name, "unknown"), - input: item.input ?? {}, + name: redactHomePathUserSegments(asString(item.name, "unknown")), + toolUseId: asString(item.id), + input: redactHomePathUserSegmentsInValue(item.input ?? {}), }]; } @@ -160,24 +167,28 @@ function parseCodexItem( asString(item.result) || stringifyUnknown(item.content ?? item.output ?? item.result); const isError = item.is_error === true || asString(item.status) === "error"; - return [{ kind: "tool_result", ts, toolUseId, content, isError }]; + return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }]; } if (itemType === "error" && phase === "completed") { const text = errorText(item.message ?? item.error ?? item); - return [{ kind: "stderr", ts, text: text || "error" }]; + return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }]; } const id = asString(item.id); const status = asString(item.status); const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" "); - return [{ kind: "system", ts, text: `item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}` }]; + return [{ + kind: "system", + ts, + text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`), + }]; } export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] { const parsed = asRecord(safeJsonParse(line)); if (!parsed) { - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; } const type = asString(parsed.type); @@ -187,8 +198,8 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "init", ts, - model: asString(parsed.model, "codex"), - sessionId: threadId, + model: redactHomePathUserSegments(asString(parsed.model, "codex")), + sessionId: redactHomePathUserSegments(threadId), }]; } @@ -210,15 +221,15 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "result", ts, - text: asString(parsed.result), + text: redactHomePathUserSegments(asString(parsed.result)), inputTokens, outputTokens, cachedTokens, costUsd: asNumber(parsed.total_cost_usd), - subtype: asString(parsed.subtype), + subtype: redactHomePathUserSegments(asString(parsed.subtype)), isError: parsed.is_error === true, errors: Array.isArray(parsed.errors) - ? parsed.errors.map(errorText).filter(Boolean) + ? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean) : [], }]; } @@ -232,21 +243,21 @@ export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[ return [{ kind: "result", ts, - text: asString(parsed.result), + text: redactHomePathUserSegments(asString(parsed.result)), inputTokens, outputTokens, cachedTokens, costUsd: asNumber(parsed.total_cost_usd), - subtype: asString(parsed.subtype, "turn.failed"), + subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")), isError: true, - errors: message ? [message] : [], + errors: message ? [redactHomePathUserSegments(message)] : [], }]; } if (type === "error") { const message = errorText(parsed.message ?? parsed.error ?? parsed); - return [{ kind: "stderr", ts, text: message || line }]; + return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }]; } - return [{ kind: "stdout", ts, text: line }]; + return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }]; } diff --git a/packages/adapters/cursor-local/CHANGELOG.md b/packages/adapters/cursor-local/CHANGELOG.md index ae97efac..df26ccde 100644 --- a/packages/adapters/cursor-local/CHANGELOG.md +++ b/packages/adapters/cursor-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-cursor-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/cursor-local/package.json b/packages/adapters/cursor-local/package.json index 67434641..3561f0ff 100644 --- a/packages/adapters/cursor-local/package.json +++ b/packages/adapters/cursor-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-cursor-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 162ed5c6..5f369e11 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import type { Dirent } from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -13,8 +12,12 @@ import { redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, + joinPromptSections, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "../index.js"; @@ -23,10 +26,6 @@ import { normalizeCursorStreamLine } from "../shared/stream.js"; import { hasCursorTrustBypassArg } from "../shared/trust.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; function firstNonEmptyLine(text: string): string { return ( @@ -82,16 +81,9 @@ function cursorSkillsHome(): string { return path.join(os.homedir(), ".cursor", "skills"); } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - type EnsureCursorSkillsInjectedOptions = { skillsDir?: string | null; + skillsEntries?: Array<{ name: string; source: string }>; skillsHome?: string; linkSkill?: (source: string, target: string) => Promise; }; @@ -100,8 +92,13 @@ export async function ensureCursorSkillsInjected( onLog: AdapterExecutionContext["onLog"], options: EnsureCursorSkillsInjectedOptions = {}, ) { - const skillsDir = options.skillsDir ?? await resolvePaperclipSkillsDir(); - if (!skillsDir) return; + const skillsEntries = options.skillsEntries + ?? (options.skillsDir + ? (await fs.readdir(options.skillsDir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ name: entry.name, source: path.join(options.skillsDir!, entry.name) })) + : await listPaperclipSkillEntries(__moduleDir)); + if (skillsEntries.length === 0) return; const skillsHome = options.skillsHome ?? cursorSkillsHome(); try { @@ -113,31 +110,26 @@ export async function ensureCursorSkillsInjected( ); return; } - - let entries: Dirent[]; - try { - entries = await fs.readdir(skillsDir, { withFileTypes: true }); - } catch (err) { + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { await onLog( "stderr", - `[paperclip] Failed to read Paperclip skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`, + `[paperclip] Removed maintainer-only Cursor skill "${skillName}" from ${skillsHome}\n`, ); - return; } - const linkSkill = options.linkSkill ?? ((source: string, target: string) => fs.symlink(source, target)); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + for (const entry of skillsEntries) { const target = path.join(skillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; - try { - await linkSkill(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); + if (result === "skipped") continue; + await onLog( "stderr", - `[paperclip] Injected Cursor skill "${entry.name}" into ${skillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Cursor skill "${entry.name}" into ${skillsHome}\n`, ); } catch (err) { await onLog( @@ -165,6 +157,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, @@ -238,6 +231,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); } @@ -277,6 +273,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); - const prompt = `${instructionsPrefix}${paperclipEnvNote}${renderedPrompt}`; + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + paperclipEnvNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildArgs = (resumeSessionId: string | null) => { const args = ["-p", "--output-format", "stream-json", "--workspace", cwd]; @@ -349,6 +368,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise, ts: string): T kind: "tool_call", ts, name: toolName, + toolUseId: callId, input, }]; } diff --git a/packages/adapters/gemini-local/package.json b/packages/adapters/gemini-local/package.json new file mode 100644 index 00000000..1d482fb1 --- /dev/null +++ b/packages/adapters/gemini-local/package.json @@ -0,0 +1,51 @@ +{ + "name": "@paperclipai/adapter-gemini-local", + "version": "0.3.1", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./server": "./src/server/index.ts", + "./ui": "./src/ui/index.ts", + "./cli": "./src/cli/index.ts" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./ui": { + "types": "./dist/ui/index.d.ts", + "import": "./dist/ui/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist", + "skills" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/adapter-utils": "workspace:*", + "picocolors": "^1.1.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/adapters/gemini-local/src/cli/format-event.ts b/packages/adapters/gemini-local/src/cli/format-event.ts new file mode 100644 index 00000000..48611f02 --- /dev/null +++ b/packages/adapters/gemini-local/src/cli/format-event.ts @@ -0,0 +1,208 @@ +import pc from "picocolors"; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + if (text) console.log(colorize(`${prefix}: ${text}`)); + return; + } + + const message = asRecord(messageRaw); + if (!message) return; + + const directText = asString(message.text).trim(); + if (directText) console.log(colorize(`${prefix}: ${directText}`)); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) console.log(colorize(`${prefix}: ${text}`)); + continue; + } + + if (type === "thinking") { + const text = asString(part.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + continue; + } + + if (type === "tool_call") { + const name = asString(part.name, asString(part.tool, "tool")); + console.log(pc.yellow(`tool_call: ${name}`)); + const input = part.input ?? part.arguments ?? part.args; + if (input !== undefined) console.log(pc.gray(stringifyUnknown(input))); + continue; + } + + if (type === "tool_result" || type === "tool_response") { + const isError = part.is_error === true || asString(part.status).toLowerCase() === "error"; + const contentText = + asString(part.output) || + asString(part.text) || + asString(part.result) || + stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response); + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + if (contentText) console.log((isError ? pc.red : pc.gray)(contentText)); + } + } +} + +function printUsage(parsed: Record) { + const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata); + const usageMetadata = asRecord(usage?.usageMetadata); + const source = usageMetadata ?? usage ?? {}; + const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))); + const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))); + const cached = asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)), + ); + const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))); + console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`)); +} + +export function printGeminiStreamEvent(raw: string, _debug: boolean): void { + const line = raw.trim(); + if (!line) return; + + let parsed: Record | null = null; + try { + parsed = JSON.parse(line) as Record; + } catch { + console.log(line); + return; + } + + const type = asString(parsed.type); + + if (type === "system") { + const subtype = asString(parsed.subtype); + if (subtype === "init") { + const sessionId = + asString(parsed.session_id) || + asString(parsed.sessionId) || + asString(parsed.sessionID) || + asString(parsed.checkpoint_id); + const model = asString(parsed.model); + const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""] + .filter(Boolean) + .join(", "); + console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`)); + return; + } + if (subtype === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + if (text) console.log(pc.red(`error: ${text}`)); + return; + } + console.log(pc.blue(`system: ${subtype || "event"}`)); + return; + } + + if (type === "assistant") { + printTextMessage("assistant", pc.green, parsed.message); + return; + } + + if (type === "user") { + printTextMessage("user", pc.gray, parsed.message); + return; + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + if (text) console.log(pc.gray(`thinking: ${text}`)); + return; + } + + if (type === "tool_call") { + const subtype = asString(parsed.subtype).trim().toLowerCase(); + const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall); + const [toolName] = toolCall ? Object.keys(toolCall) : []; + if (!toolCall || !toolName) { + console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`)); + return; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + if (subtype === "started" || subtype === "start") { + console.log(pc.yellow(`tool_call: ${toolName}`)); + console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload))); + return; + } + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const isError = + parsed.is_error === true || + payload.is_error === true || + payload.error !== undefined || + asString(payload.status).toLowerCase() === "error"; + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error))); + return; + } + console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`)); + return; + } + + if (type === "result") { + printUsage(parsed); + const subtype = asString(parsed.subtype, "result"); + const isError = parsed.is_error === true; + if (subtype || isError) { + console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`)); + } + return; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + if (text) console.log(pc.red(`error: ${text}`)); + return; + } + + console.log(line); +} diff --git a/packages/adapters/gemini-local/src/cli/index.ts b/packages/adapters/gemini-local/src/cli/index.ts new file mode 100644 index 00000000..49ec4426 --- /dev/null +++ b/packages/adapters/gemini-local/src/cli/index.ts @@ -0,0 +1 @@ +export { printGeminiStreamEvent } from "./format-event.js"; diff --git a/packages/adapters/gemini-local/src/index.ts b/packages/adapters/gemini-local/src/index.ts new file mode 100644 index 00000000..64b7b99f --- /dev/null +++ b/packages/adapters/gemini-local/src/index.ts @@ -0,0 +1,47 @@ +export const type = "gemini_local"; +export const label = "Gemini CLI (local)"; +export const DEFAULT_GEMINI_LOCAL_MODEL = "auto"; + +export const models = [ + { id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + { id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" }, + { id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" }, + { id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" }, +]; + +export const agentConfigurationDoc = `# gemini_local agent configuration + +Adapter: gemini_local + +Use when: +- You want Paperclip to run the Gemini CLI locally on the host machine +- You want Gemini chat sessions resumed across heartbeats with --resume +- You want Paperclip skills injected locally without polluting the global environment + +Don't use when: +- You need webhook-style external invocation (use http or openclaw_gateway) +- You only need a one-shot script without an AI coding agent loop (use process) +- Gemini CLI is not installed on the machine that runs Paperclip + +Core fields: +- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) +- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt +- promptTemplate (string, optional): run prompt template +- model (string, optional): Gemini model id. Defaults to auto. +- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none) +- command (string, optional): defaults to "gemini" +- extraArgs (string[], optional): additional CLI args +- env (object, optional): KEY=VALUE environment variables + +Operational fields: +- timeoutSec (number, optional): run timeout in seconds +- graceSec (number, optional): SIGTERM grace period in seconds + +Notes: +- Runs use positional prompt arguments, not stdin. +- Sessions resume with --resume when stored session cwd matches the current cwd. +- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location. +- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login. +`; diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts new file mode 100644 index 00000000..e2769c3e --- /dev/null +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -0,0 +1,452 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; +import { + asBoolean, + asNumber, + asString, + asStringArray, + buildPaperclipEnv, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePaperclipSkillSymlink, + joinPromptSections, + ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, + parseObject, + redactEnvForLogs, + renderTemplate, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; +import { + describeGeminiFailure, + detectGeminiAuthRequired, + isGeminiTurnLimitResult, + isGeminiUnknownSessionError, + parseGeminiJsonl, +} from "./parse.js"; +import { firstNonEmptyLine } from "./utils.js"; + +const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); + +function hasNonEmptyEnvValue(env: Record, key: string): boolean { + const raw = env[key]; + return typeof raw === "string" && raw.trim().length > 0; +} + +function resolveGeminiBillingType(env: Record): "api" | "subscription" { + return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY") + ? "api" + : "subscription"; +} + +function renderPaperclipEnvNote(env: Record): string { + const paperclipKeys = Object.keys(env) + .filter((key) => key.startsWith("PAPERCLIP_")) + .sort(); + if (paperclipKeys.length === 0) return ""; + return [ + "Paperclip runtime note:", + `The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`, + "Do not assume these variables are missing without checking your shell environment.", + "", + "", + ].join("\n"); +} + +function renderApiAccessNote(env: Record): string { + if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return ""; + return [ + "Paperclip API access note:", + "Use run_shell_command with curl to make Paperclip API requests.", + "GET example:", + ` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" \\"$PAPERCLIP_API_URL/api/agents/me\\"" })`, + "POST/PATCH example:", + ` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $PAPERCLIP_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID\\" -d '{...}' \\"$PAPERCLIP_API_URL/api/issues/{id}/checkout\\"" })`, + "", + "", + ].join("\n"); +} + +function geminiSkillsHome(): string { + return path.join(os.homedir(), ".gemini", "skills"); +} + +/** + * Inject Paperclip skills directly into `~/.gemini/skills/` via symlinks. + * This avoids needing GEMINI_CLI_HOME overrides, so the CLI naturally finds + * both its auth credentials and the injected skills in the real home directory. + */ +async function ensureGeminiSkillsInjected( + onLog: AdapterExecutionContext["onLog"], +): Promise { + const skillsEntries = await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; + + const skillsHome = geminiSkillsHome(); + try { + await fs.mkdir(skillsHome, { recursive: true }); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + return; + } + const removedSkills = await removeMaintainerOnlySkillSymlinks( + skillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Gemini skill "${skillName}" from ${skillsHome}\n`, + ); + } + + for (const entry of skillsEntries) { + const target = path.join(skillsHome, entry.name); + + try { + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; + await onLog( + "stderr", + `[paperclip] ${result === "repaired" ? "Repaired" : "Linked"} Gemini skill: ${entry.name}\n`, + ); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } +} + +export async function execute(ctx: AdapterExecutionContext): Promise { + const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; + + const promptTemplate = asString( + config.promptTemplate, + "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", + ); + const command = asString(config.command, "gemini"); + const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); + const sandbox = asBoolean(config.sandbox, false); + + const workspaceContext = parseObject(context.paperclipWorkspace); + const workspaceCwd = asString(workspaceContext.cwd, ""); + const workspaceSource = asString(workspaceContext.source, ""); + const workspaceId = asString(workspaceContext.workspaceId, ""); + const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); + const workspaceRepoRef = asString(workspaceContext.repoRef, ""); + const agentHome = asString(workspaceContext.agentHome, ""); + const workspaceHints = Array.isArray(context.paperclipWorkspaces) + ? context.paperclipWorkspaces.filter( + (value): value is Record => typeof value === "object" && value !== null, + ) + : []; + const configuredCwd = asString(config.cwd, ""); + const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; + const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; + const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + await ensureGeminiSkillsInjected(onLog); + + const envConfig = parseObject(config.env); + const hasExplicitApiKey = + typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; + const env: Record = { ...buildPaperclipEnv(agent) }; + env.PAPERCLIP_RUN_ID = runId; + const wakeTaskId = + (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || + (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || + null; + const wakeReason = + typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 + ? context.wakeReason.trim() + : null; + const wakeCommentId = + (typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) || + (typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) || + null; + const approvalId = + typeof context.approvalId === "string" && context.approvalId.trim().length > 0 + ? context.approvalId.trim() + : null; + const approvalStatus = + typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 + ? context.approvalStatus.trim() + : null; + const linkedIssueIds = Array.isArray(context.issueIds) + ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; + if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; + if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; + if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; + if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; + if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; + if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; + if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; + if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; + if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; + if (agentHome) env.AGENT_HOME = agentHome; + if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); + + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + if (!hasExplicitApiKey && authToken) { + env.PAPERCLIP_API_KEY = authToken; + } + const billingType = resolveGeminiBillingType(env); + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + await ensureCommandResolvable(command, cwd, runtimeEnv); + + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stderr", + `[paperclip] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (instructionsFilePath) { + try { + const instructionsContents = await fs.readFile(instructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + await onLog( + "stderr", + `[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`, + ); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stderr", + `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, + ); + } + } + const commandNotes = (() => { + const notes: string[] = ["Prompt is passed to Gemini as the final positional argument."]; + notes.push("Added --approval-mode yolo for unattended execution."); + if (!instructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push( + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); + + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedPrompt = renderTemplate(promptTemplate, templateData); + const renderedBootstrapPrompt = + !sessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const paperclipEnvNote = renderPaperclipEnvNote(env); + const apiAccessNote = renderApiAccessNote(env); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + paperclipEnvNote, + apiAccessNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; + + const buildArgs = (resumeSessionId: string | null) => { + const args = ["--output-format", "stream-json"]; + if (resumeSessionId) args.push("--resume", resumeSessionId); + if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); + args.push("--approval-mode", "yolo"); + if (sandbox) { + args.push("--sandbox"); + } else { + args.push("--sandbox=none"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push(prompt); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "gemini_local", + command, + cwd, + commandNotes, + commandArgs: args.map((value, index) => ( + index === args.length - 1 ? `` : value + )), + env: redactEnvForLogs(env), + prompt, + promptMetrics, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env, + timeoutSec, + graceSec, + onLog, + }); + return { + proc, + parsed: parseGeminiJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { + exitCode: number | null; + signal: string | null; + timedOut: boolean; + stdout: string; + stderr: string; + }; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + isRetry = false, + ): AdapterExecutionResult => { + const authMeta = detectGeminiAuthRequired({ + parsed: attempt.parsed.resultEvent, + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }); + + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null, + clearSession: clearSessionOnMissingSession, + }; + } + + const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode); + + // On retry, don't fall back to old session ID — the old session was stale + const canFallbackToRuntimeSession = !isRetry; + const resolvedSessionId = attempt.parsed.sessionId + ?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : 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}`; + + 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, + usage: attempt.parsed.usage, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: "google", + model, + billingType, + costUsd: attempt.parsed.costUsd, + resultJson: attempt.parsed.resultEvent ?? { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + question: attempt.parsed.question, + clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId), + }; + }; + + const initial = await runAttempt(sessionId); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr) + ) { + await onLog( + "stderr", + `[paperclip] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true, true); + } + + return toResult(initial); +} diff --git a/packages/adapters/gemini-local/src/server/index.ts b/packages/adapters/gemini-local/src/server/index.ts new file mode 100644 index 00000000..1d35a2bf --- /dev/null +++ b/packages/adapters/gemini-local/src/server/index.ts @@ -0,0 +1,70 @@ +export { execute } from "./execute.js"; +export { testEnvironment } from "./test.js"; +export { + parseGeminiJsonl, + isGeminiUnknownSessionError, + describeGeminiFailure, + detectGeminiAuthRequired, + isGeminiTurnLimitResult, +} from "./parse.js"; +import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; + +function readNonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export const sessionCodec: AdapterSessionCodec = { + deserialize(raw: unknown) { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + const sessionId = + readNonEmptyString(record.sessionId) ?? + readNonEmptyString(record.session_id) ?? + readNonEmptyString(record.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(record.cwd) ?? + readNonEmptyString(record.workdir) ?? + readNonEmptyString(record.folder); + const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); + const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); + const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + serialize(params: Record | null) { + if (!params) return null; + const sessionId = + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID); + if (!sessionId) return null; + const cwd = + readNonEmptyString(params.cwd) ?? + readNonEmptyString(params.workdir) ?? + readNonEmptyString(params.folder); + const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); + const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); + const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); + return { + sessionId, + ...(cwd ? { cwd } : {}), + ...(workspaceId ? { workspaceId } : {}), + ...(repoUrl ? { repoUrl } : {}), + ...(repoRef ? { repoRef } : {}), + }; + }, + getDisplayId(params: Record | null) { + if (!params) return null; + return ( + readNonEmptyString(params.sessionId) ?? + readNonEmptyString(params.session_id) ?? + readNonEmptyString(params.sessionID) + ); + }, +}; diff --git a/packages/adapters/gemini-local/src/server/parse.ts b/packages/adapters/gemini-local/src/server/parse.ts new file mode 100644 index 00000000..4fe98fb6 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/parse.ts @@ -0,0 +1,263 @@ +import { asNumber, asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils"; + +function collectMessageText(message: unknown): string[] { + if (typeof message === "string") { + const trimmed = message.trim(); + return trimmed ? [trimmed] : []; + } + + const record = parseObject(message); + const direct = asString(record.text, "").trim(); + const lines: string[] = direct ? [direct] : []; + const content = Array.isArray(record.content) ? record.content : []; + + for (const partRaw of content) { + const part = parseObject(partRaw); + const type = asString(part.type, "").trim(); + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text, "").trim() || asString(part.content, "").trim(); + if (text) lines.push(text); + } + } + + return lines; +} + +function readSessionId(event: Record): string | null { + return ( + asString(event.session_id, "").trim() || + asString(event.sessionId, "").trim() || + asString(event.sessionID, "").trim() || + asString(event.checkpoint_id, "").trim() || + asString(event.thread_id, "").trim() || + null + ); +} + +function asErrorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = parseObject(value); + const message = + asString(rec.message, "") || + asString(rec.error, "") || + asString(rec.code, "") || + asString(rec.detail, ""); + if (message) return message; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function accumulateUsage( + target: { inputTokens: number; cachedInputTokens: number; outputTokens: number }, + usageRaw: unknown, +) { + const usage = parseObject(usageRaw); + const usageMetadata = parseObject(usage.usageMetadata); + const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage; + + target.inputTokens += asNumber( + source.input_tokens, + asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)), + ); + target.cachedInputTokens += asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)), + ); + target.outputTokens += asNumber( + source.output_tokens, + asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)), + ); +} + +export function parseGeminiJsonl(stdout: string) { + let sessionId: string | null = null; + const messages: string[] = []; + let errorMessage: string | null = null; + let costUsd: number | null = null; + let resultEvent: Record | null = null; + let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null; + const usage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + }; + + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + const event = parseJson(line); + if (!event) continue; + + const foundSessionId = readSessionId(event); + if (foundSessionId) sessionId = foundSessionId; + + const type = asString(event.type, "").trim(); + + if (type === "assistant") { + messages.push(...collectMessageText(event.message)); + const messageObj = parseObject(event.message); + const content = Array.isArray(messageObj.content) ? messageObj.content : []; + for (const partRaw of content) { + const part = parseObject(partRaw); + if (asString(part.type, "").trim() === "question") { + question = { + prompt: asString(part.prompt, "").trim(), + choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => { + const choice = parseObject(choiceRaw); + return { + key: asString(choice.key, "").trim(), + label: asString(choice.label, "").trim(), + description: asString(choice.description, "").trim() || undefined, + }; + }), + }; + break; // only one question per message + } + } + continue; + } + + if (type === "result") { + resultEvent = event; + accumulateUsage(usage, event.usage ?? event.usageMetadata); + const resultText = + asString(event.result, "").trim() || + asString(event.text, "").trim() || + asString(event.response, "").trim(); + if (resultText && messages.length === 0) messages.push(resultText); + costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error"; + if (isError) { + const text = asErrorText(event.error ?? event.message ?? event.result).trim(); + if (text) errorMessage = text; + } + continue; + } + + if (type === "error") { + const text = asErrorText(event.error ?? event.message ?? event.detail).trim(); + if (text) errorMessage = text; + continue; + } + + if (type === "system") { + const subtype = asString(event.subtype, "").trim().toLowerCase(); + if (subtype === "error") { + const text = asErrorText(event.error ?? event.message ?? event.detail).trim(); + if (text) errorMessage = text; + } + continue; + } + + if (type === "text") { + const part = parseObject(event.part); + const text = asString(part.text, "").trim(); + if (text) messages.push(text); + continue; + } + + if (type === "step_finish" || event.usage || event.usageMetadata) { + accumulateUsage(usage, event.usage ?? event.usageMetadata); + costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd; + continue; + } + } + + return { + sessionId, + summary: messages.join("\n\n").trim(), + usage, + costUsd, + errorMessage, + resultEvent, + question, + }; +} + +export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean { + const haystack = `${stdout}\n${stderr}` + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); + + return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test( + haystack, + ); +} + +function extractGeminiErrorMessages(parsed: Record): string[] { + const messages: string[] = []; + const errorMsg = asString(parsed.error, "").trim(); + if (errorMsg) messages.push(errorMsg); + + const raw = Array.isArray(parsed.errors) ? parsed.errors : []; + for (const entry of raw) { + if (typeof entry === "string") { + const msg = entry.trim(); + if (msg) messages.push(msg); + continue; + } + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue; + const obj = entry as Record; + const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, ""); + if (msg) { + messages.push(msg); + continue; + } + try { + messages.push(JSON.stringify(obj)); + } catch { + // skip non-serializable entry + } + } + + return messages; +} + +export function describeGeminiFailure(parsed: Record): string | null { + const status = asString(parsed.status, ""); + const errors = extractGeminiErrorMessages(parsed); + + const detail = errors[0] ?? ""; + const parts = ["Gemini run failed"]; + if (status) parts.push(`status=${status}`); + if (detail) parts.push(detail); + return parts.length > 1 ? parts.join(": ") : null; +} + +const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i; + +export function detectGeminiAuthRequired(input: { + parsed: Record | null; + stdout: string; + stderr: string; +}): { requiresAuth: boolean } { + const errors = extractGeminiErrorMessages(input.parsed ?? {}); + const messages = [...errors, input.stdout, input.stderr] + .join("\n") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line)); + return { requiresAuth }; +} + +export function isGeminiTurnLimitResult( + parsed: Record | null | undefined, + exitCode?: number | null, +): boolean { + if (exitCode === 53) return true; + if (!parsed) return false; + + const status = asString(parsed.status, "").trim().toLowerCase(); + if (status === "turn_limit" || status === "max_turns") return true; + + const error = asString(parsed.error, "").trim(); + return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error); +} diff --git a/packages/adapters/gemini-local/src/server/test.ts b/packages/adapters/gemini-local/src/server/test.ts new file mode 100644 index 00000000..8f63e5e2 --- /dev/null +++ b/packages/adapters/gemini-local/src/server/test.ts @@ -0,0 +1,223 @@ +import path from "node:path"; +import type { + AdapterEnvironmentCheck, + AdapterEnvironmentTestContext, + AdapterEnvironmentTestResult, +} from "@paperclipai/adapter-utils"; +import { + asBoolean, + asString, + asStringArray, + ensureAbsoluteDirectory, + ensureCommandResolvable, + ensurePathInEnv, + parseObject, + runChildProcess, +} from "@paperclipai/adapter-utils/server-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; +import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js"; +import { firstNonEmptyLine } from "./utils.js"; + +function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { + if (checks.some((check) => check.level === "error")) return "fail"; + if (checks.some((check) => check.level === "warn")) return "warn"; + return "pass"; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function commandLooksLike(command: string, expected: string): boolean { + const base = path.basename(command).toLowerCase(); + return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`; +} + +function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null { + const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout); + if (!raw) return null; + const clean = raw.replace(/\s+/g, " ").trim(); + const max = 240; + return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean; +} + +export async function testEnvironment( + ctx: AdapterEnvironmentTestContext, +): Promise { + const checks: AdapterEnvironmentCheck[] = []; + const config = parseObject(ctx.config); + const command = asString(config.command, "gemini"); + const cwd = asString(config.cwd, process.cwd()); + + try { + await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); + checks.push({ + code: "gemini_cwd_valid", + level: "info", + message: `Working directory is valid: ${cwd}`, + }); + } catch (err) { + checks.push({ + code: "gemini_cwd_invalid", + level: "error", + message: err instanceof Error ? err.message : "Invalid working directory", + detail: cwd, + }); + } + + const envConfig = parseObject(config.env); + const env: Record = {}; + for (const [key, value] of Object.entries(envConfig)) { + if (typeof value === "string") env[key] = value; + } + const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); + checks.push({ + code: "gemini_command_resolvable", + level: "info", + message: `Command is executable: ${command}`, + }); + } catch (err) { + checks.push({ + code: "gemini_command_unresolvable", + level: "error", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, + }); + } + + const configGeminiApiKey = env.GEMINI_API_KEY; + const hostGeminiApiKey = process.env.GEMINI_API_KEY; + const configGoogleApiKey = env.GOOGLE_API_KEY; + const hostGoogleApiKey = process.env.GOOGLE_API_KEY; + const hasGca = env.GOOGLE_GENAI_USE_GCA === "true" || process.env.GOOGLE_GENAI_USE_GCA === "true"; + if ( + isNonEmpty(configGeminiApiKey) || + isNonEmpty(hostGeminiApiKey) || + isNonEmpty(configGoogleApiKey) || + isNonEmpty(hostGoogleApiKey) || + hasGca + ) { + const source = hasGca + ? "Google account login (GCA)" + : isNonEmpty(configGeminiApiKey) || isNonEmpty(configGoogleApiKey) + ? "adapter config env" + : "server environment"; + checks.push({ + code: "gemini_api_key_present", + level: "info", + message: "Gemini API credentials are set for CLI authentication.", + detail: `Detected in ${source}.`, + }); + } else { + checks.push({ + code: "gemini_api_key_missing", + level: "info", + message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).", + hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in adapter env, or run `gemini auth login`.", + }); + } + + const canRunProbe = + checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable"); + if (canRunProbe) { + if (!commandLooksLike(command, "gemini")) { + checks.push({ + code: "gemini_hello_probe_skipped_custom_command", + level: "info", + message: "Skipped hello probe because command is not `gemini`.", + detail: command, + hint: "Use the `gemini` CLI command to run the automatic installation and auth probe.", + }); + } else { + const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim(); + const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default"); + const sandbox = asBoolean(config.sandbox, false); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const args = ["--output-format", "stream-json"]; + if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model); + if (approvalMode !== "default") args.push("--approval-mode", approvalMode); + if (sandbox) { + args.push("--sandbox"); + } else { + args.push("--sandbox=none"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + args.push("Respond with hello."); + + const probe = await runChildProcess( + `gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env, + timeoutSec: 45, + graceSec: 5, + onLog: async () => { }, + }, + ); + const parsed = parseGeminiJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authMeta = detectGeminiAuthRequired({ + parsed: parsed.resultEvent, + stdout: probe.stdout, + stderr: probe.stderr, + }); + + if (probe.timedOut) { + checks.push({ + code: "gemini_hello_probe_timed_out", + level: "warn", + message: "Gemini hello probe timed out.", + hint: "Retry the probe. If this persists, verify Gemini can run `Respond with hello.` from this directory manually.", + }); + } else if ((probe.exitCode ?? 1) === 0) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "gemini_hello_probe_passed" : "gemini_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "Gemini hello probe succeeded." + : "Gemini probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Try `gemini --output-format json \"Respond with hello.\"` manually to inspect full output.", + }), + }); + } else if (authMeta.requiresAuth) { + checks.push({ + code: "gemini_hello_probe_auth_required", + level: "warn", + message: "Gemini CLI is installed, but authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY in adapter env/shell, then retry the probe.", + }); + } else { + checks.push({ + code: "gemini_hello_probe_failed", + level: "error", + message: "Gemini hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `gemini --output-format json \"Respond with hello.\"` manually in this working directory to debug.", + }); + } + } + } + + return { + adapterType: ctx.adapterType, + status: summarizeStatus(checks), + checks, + testedAt: new Date().toISOString(), + }; +} diff --git a/packages/adapters/gemini-local/src/server/utils.ts b/packages/adapters/gemini-local/src/server/utils.ts new file mode 100644 index 00000000..fb11c75d --- /dev/null +++ b/packages/adapters/gemini-local/src/server/utils.ts @@ -0,0 +1,8 @@ +export function firstNonEmptyLine(text: string): string { + return ( + text + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} diff --git a/packages/adapters/gemini-local/src/ui/build-config.ts b/packages/adapters/gemini-local/src/ui/build-config.ts new file mode 100644 index 00000000..5ce333a4 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/build-config.ts @@ -0,0 +1,76 @@ +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; +import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js"; + +function parseCommaArgs(value: string): string[] { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseEnvVars(text: string): Record { + const env: Record = {}; + for (const line of text.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + env[key] = value; + } + return env; +} + +function parseEnvBindings(bindings: unknown): Record { + if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {}; + const env: Record = {}; + for (const [key, raw] of Object.entries(bindings)) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue; + if (typeof raw === "string") { + env[key] = { type: "plain", value: raw }; + continue; + } + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue; + const rec = raw as Record; + if (rec.type === "plain" && typeof rec.value === "string") { + env[key] = { type: "plain", value: rec.value }; + continue; + } + if (rec.type === "secret_ref" && typeof rec.secretId === "string") { + env[key] = { + type: "secret_ref", + secretId: rec.secretId, + ...(typeof rec.version === "number" || rec.version === "latest" + ? { version: rec.version } + : {}), + }; + } + } + return env; +} + +export function buildGeminiLocalConfig(v: CreateConfigValues): Record { + const ac: Record = {}; + if (v.cwd) ac.cwd = v.cwd; + if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath; + if (v.promptTemplate) ac.promptTemplate = v.promptTemplate; + if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt; + ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL; + ac.timeoutSec = 0; + ac.graceSec = 15; + const env = parseEnvBindings(v.envBindings); + const legacy = parseEnvVars(v.envVars); + for (const [key, value] of Object.entries(legacy)) { + if (!Object.prototype.hasOwnProperty.call(env, key)) { + env[key] = { type: "plain", value }; + } + } + if (Object.keys(env).length > 0) ac.env = env; + ac.sandbox = !v.dangerouslyBypassSandbox; + + if (v.command) ac.command = v.command; + if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs); + return ac; +} diff --git a/packages/adapters/gemini-local/src/ui/index.ts b/packages/adapters/gemini-local/src/ui/index.ts new file mode 100644 index 00000000..5d7708b1 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/index.ts @@ -0,0 +1,2 @@ +export { parseGeminiStdoutLine } from "./parse-stdout.js"; +export { buildGeminiLocalConfig } from "./build-config.js"; diff --git a/packages/adapters/gemini-local/src/ui/parse-stdout.ts b/packages/adapters/gemini-local/src/ui/parse-stdout.ts new file mode 100644 index 00000000..47426fa3 --- /dev/null +++ b/packages/adapters/gemini-local/src/ui/parse-stdout.ts @@ -0,0 +1,274 @@ +import type { TranscriptEntry } from "@paperclipai/adapter-utils"; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function asString(value: unknown, fallback = ""): string { + return typeof value === "string" ? value : fallback; +} + +function asNumber(value: unknown, fallback = 0): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function stringifyUnknown(value: unknown): string { + if (typeof value === "string") return value; + if (value === null || value === undefined) return ""; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function errorText(value: unknown): string { + if (typeof value === "string") return value; + const rec = asRecord(value); + if (!rec) return ""; + const msg = + (typeof rec.message === "string" && rec.message) || + (typeof rec.error === "string" && rec.error) || + (typeof rec.code === "string" && rec.code) || + ""; + if (msg) return msg; + try { + return JSON.stringify(rec); + } catch { + return ""; + } +} + +function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + return text ? [{ kind, ts, text }] : []; + } + + const message = asRecord(messageRaw); + if (!message) return []; + + const entries: TranscriptEntry[] = []; + const directText = asString(message.text).trim(); + if (directText) entries.push({ kind, ts, text: directText }); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + if (type !== "output_text" && type !== "text" && type !== "content") continue; + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) entries.push({ kind, ts, text }); + } + + return entries; +} + +function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] { + if (typeof messageRaw === "string") { + const text = messageRaw.trim(); + return text ? [{ kind: "assistant", ts, text }] : []; + } + + const message = asRecord(messageRaw); + if (!message) return []; + + const entries: TranscriptEntry[] = []; + const directText = asString(message.text).trim(); + if (directText) entries.push({ kind: "assistant", ts, text: directText }); + + const content = Array.isArray(message.content) ? message.content : []; + for (const partRaw of content) { + const part = asRecord(partRaw); + if (!part) continue; + const type = asString(part.type).trim(); + + if (type === "output_text" || type === "text" || type === "content") { + const text = asString(part.text).trim() || asString(part.content).trim(); + if (text) entries.push({ kind: "assistant", ts, text }); + continue; + } + + if (type === "thinking") { + const text = asString(part.text).trim(); + if (text) entries.push({ kind: "thinking", ts, text }); + continue; + } + + if (type === "tool_call") { + const name = asString(part.name, asString(part.tool, "tool")); + entries.push({ + kind: "tool_call", + ts, + name, + input: part.input ?? part.arguments ?? part.args ?? {}, + }); + continue; + } + + if (type === "tool_result" || type === "tool_response") { + const toolUseId = + asString(part.tool_use_id) || + asString(part.toolUseId) || + asString(part.call_id) || + asString(part.id) || + "tool_result"; + const contentText = + asString(part.output) || + asString(part.text) || + asString(part.result) || + stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response); + const isError = part.is_error === true || asString(part.status).toLowerCase() === "error"; + entries.push({ + kind: "tool_result", + ts, + toolUseId, + content: contentText, + isError, + }); + } + } + + return entries; +} + +function parseTopLevelToolEvent(parsed: Record, ts: string): TranscriptEntry[] { + const subtype = asString(parsed.subtype).trim().toLowerCase(); + const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call"))); + const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall); + if (!toolCall) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + + const [toolName] = Object.keys(toolCall); + if (!toolName) { + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }]; + } + const payload = asRecord(toolCall[toolName]) ?? {}; + + if (subtype === "started" || subtype === "start") { + return [{ + kind: "tool_call", + ts, + name: toolName, + input: payload.args ?? payload.input ?? payload.arguments ?? payload, + }]; + } + + if (subtype === "completed" || subtype === "complete" || subtype === "finished") { + const result = payload.result ?? payload.output ?? payload.error; + const isError = + parsed.is_error === true || + payload.is_error === true || + payload.error !== undefined || + asString(payload.status).toLowerCase() === "error"; + return [{ + kind: "tool_result", + ts, + toolUseId: callId, + content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`, + isError, + }]; + } + + return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }]; +} + +function readSessionId(parsed: Record): string { + return ( + asString(parsed.session_id) || + asString(parsed.sessionId) || + asString(parsed.sessionID) || + asString(parsed.checkpoint_id) || + asString(parsed.thread_id) + ); +} + +function readUsage(parsed: Record) { + const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata); + const usageMetadata = asRecord(usage?.usageMetadata); + const source = usageMetadata ?? usage ?? {}; + return { + inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))), + outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))), + cachedTokens: asNumber( + source.cached_input_tokens, + asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)), + ), + }; +} + +export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] { + const parsed = asRecord(safeJsonParse(line)); + if (!parsed) { + return [{ kind: "stdout", ts, text: line }]; + } + + const type = asString(parsed.type); + + if (type === "system") { + const subtype = asString(parsed.subtype); + if (subtype === "init") { + const sessionId = readSessionId(parsed); + return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }]; + } + if (subtype === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + return [{ kind: "stderr", ts, text: text || "error" }]; + } + return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }]; + } + + if (type === "assistant") { + return parseAssistantMessage(parsed.message, ts); + } + + if (type === "user") { + return collectTextEntries(parsed.message, ts, "user"); + } + + if (type === "thinking") { + const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim(); + return text ? [{ kind: "thinking", ts, text }] : []; + } + + if (type === "tool_call") { + return parseTopLevelToolEvent(parsed, ts); + } + + if (type === "result") { + const usage = readUsage(parsed); + const errors = parsed.is_error === true + ? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean) + : []; + return [{ + kind: "result", + ts, + text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response), + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedTokens: usage.cachedTokens, + costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))), + subtype: asString(parsed.subtype, "result"), + isError: parsed.is_error === true, + errors, + }]; + } + + if (type === "error") { + const text = errorText(parsed.error ?? parsed.message ?? parsed.detail); + return [{ kind: "stderr", ts, text: text || "error" }]; + } + + return [{ kind: "stdout", ts, text: line }]; +} diff --git a/packages/adapters/gemini-local/tsconfig.json b/packages/adapters/gemini-local/tsconfig.json new file mode 100644 index 00000000..2f355cfe --- /dev/null +++ b/packages/adapters/gemini-local/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/adapters/openclaw-gateway/CHANGELOG.md b/packages/adapters/openclaw-gateway/CHANGELOG.md index 8b6357e3..f78f5181 100644 --- a/packages/adapters/openclaw-gateway/CHANGELOG.md +++ b/packages/adapters/openclaw-gateway/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-openclaw-gateway +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/openclaw-gateway/package.json b/packages/adapters/openclaw-gateway/package.json index c81ee740..323d09a2 100644 --- a/packages/adapters/openclaw-gateway/package.json +++ b/packages/adapters/openclaw-gateway/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-openclaw-gateway", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index ffd55a05..eaacbd33 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -1069,7 +1069,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { ...payloadTemplate, - paperclip: paperclipPayload, message, sessionKey, idempotencyKey: ctx.runId, diff --git a/packages/adapters/opencode-local/CHANGELOG.md b/packages/adapters/opencode-local/CHANGELOG.md index 904b21de..9ccc9e8d 100644 --- a/packages/adapters/opencode-local/CHANGELOG.md +++ b/packages/adapters/opencode-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-opencode-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/opencode-local/package.json b/packages/adapters/opencode-local/package.json index cf2d078a..e2816953 100644 --- a/packages/adapters/opencode-local/package.json +++ b/packages/adapters/opencode-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-opencode-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 970896af..98285cfc 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -9,6 +9,7 @@ import { asStringArray, parseObject, buildPaperclipEnv, + joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, @@ -99,6 +100,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, @@ -150,6 +152,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); for (const [key, value] of Object.entries(envConfig)) { @@ -233,7 +236,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, + }; const buildArgs = (resumeSessionId: string | null) => { const args = ["run", "--format", "json"]; @@ -264,6 +286,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise`], env: redactEnvForLogs(env), prompt, + promptMetrics, context, }); } diff --git a/packages/adapters/opencode-local/src/server/models.ts b/packages/adapters/opencode-local/src/server/models.ts index dd2eb2c6..a4d1a46d 100644 --- a/packages/adapters/opencode-local/src/server/models.ts +++ b/packages/adapters/opencode-local/src/server/models.ts @@ -7,6 +7,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; const MODELS_CACHE_TTL_MS = 60_000; +const MODELS_DISCOVERY_TIMEOUT_MS = 20_000; function resolveOpenCodeCommand(input: unknown): string { const envOverride = @@ -115,14 +116,14 @@ export async function discoverOpenCodeModels(input: { { cwd, env: runtimeEnv, - timeoutSec: 20, + timeoutSec: MODELS_DISCOVERY_TIMEOUT_MS / 1000, graceSec: 3, onLog: async () => {}, }, ); if (result.timedOut) { - throw new Error("`opencode models` timed out."); + throw new Error(`\`opencode models\` timed out after ${MODELS_DISCOVERY_TIMEOUT_MS / 1000}s.`); } if ((result.exitCode ?? 1) !== 0) { const detail = firstNonEmptyLine(result.stderr) || firstNonEmptyLine(result.stdout); diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 3abfd6cd..0d425cf1 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -55,6 +55,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record, ts: string): TranscriptEn kind: "tool_call", ts, name: toolName, + toolUseId: asString(part.callID) || asString(part.id) || undefined, input, }; diff --git a/packages/adapters/pi-local/CHANGELOG.md b/packages/adapters/pi-local/CHANGELOG.md index f7297faa..fb3c93a4 100644 --- a/packages/adapters/pi-local/CHANGELOG.md +++ b/packages/adapters/pi-local/CHANGELOG.md @@ -1,5 +1,13 @@ # @paperclipai/adapter-pi-local +## 0.3.1 + +### Patch Changes + +- Stable release preparation for 0.3.1 +- Updated dependencies + - @paperclipai/adapter-utils@0.3.1 + ## 0.3.0 ### Minor Changes diff --git a/packages/adapters/pi-local/package.json b/packages/adapters/pi-local/package.json index 442d83d2..c286f84e 100644 --- a/packages/adapters/pi-local/package.json +++ b/packages/adapters/pi-local/package.json @@ -1,6 +1,6 @@ { "name": "@paperclipai/adapter-pi-local", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "exports": { ".": "./src/index.ts", diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 23cad28b..85a0d844 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -9,10 +9,14 @@ import { asStringArray, parseObject, buildPaperclipEnv, + joinPromptSections, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, + ensurePaperclipSkillSymlink, ensurePathInEnv, + listPaperclipSkillEntries, + removeMaintainerOnlySkillSymlinks, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -20,10 +24,6 @@ import { isPiUnknownSessionError, parsePiJsonl } from "./parse.js"; import { ensurePiModelConfiguredAndAvailable } from "./models.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const PAPERCLIP_SKILLS_CANDIDATES = [ - path.resolve(__moduleDir, "../../skills"), - path.resolve(__moduleDir, "../../../../../skills"), -]; const PAPERCLIP_SESSIONS_DIR = path.join(os.homedir(), ".pi", "paperclips"); @@ -50,34 +50,32 @@ function parseModelId(model: string | null): string | null { return trimmed.slice(trimmed.indexOf("/") + 1).trim() || null; } -async function resolvePaperclipSkillsDir(): Promise { - for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) { - const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false); - if (isDir) return candidate; - } - return null; -} - async function ensurePiSkillsInjected(onLog: AdapterExecutionContext["onLog"]) { - const skillsDir = await resolvePaperclipSkillsDir(); - if (!skillsDir) return; + const skillsEntries = await listPaperclipSkillEntries(__moduleDir); + if (skillsEntries.length === 0) return; const piSkillsHome = path.join(os.homedir(), ".pi", "agent", "skills"); await fs.mkdir(piSkillsHome, { recursive: true }); - - const entries = await fs.readdir(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const source = path.join(skillsDir, entry.name); + const removedSkills = await removeMaintainerOnlySkillSymlinks( + piSkillsHome, + skillsEntries.map((entry) => entry.name), + ); + for (const skillName of removedSkills) { + await onLog( + "stderr", + `[paperclip] Removed maintainer-only Pi skill "${skillName}" from ${piSkillsHome}\n`, + ); + } + + for (const entry of skillsEntries) { const target = path.join(piSkillsHome, entry.name); - const existing = await fs.lstat(target).catch(() => null); - if (existing) continue; try { - await fs.symlink(source, target); + const result = await ensurePaperclipSkillSymlink(entry.source, target); + if (result === "skipped") continue; await onLog( "stderr", - `[paperclip] Injected Pi skill "${entry.name}" into ${piSkillsHome}\n`, + `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Pi skill "${entry.name}" into ${piSkillsHome}\n`, ); } catch (err) { await onLog( @@ -119,6 +117,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise => typeof value === "object" && value !== null, @@ -178,6 +177,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); for (const [key, value] of Object.entries(envConfig)) { @@ -273,7 +273,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const userPrompt = joinPromptSections([ + renderedBootstrapPrompt, + sessionHandoffNote, + renderedHeartbeatPrompt, + ]); + const promptMetrics = { + systemPromptChars: renderedSystemPromptExtension.length, + promptChars: userPrompt.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedHeartbeatPrompt.length, + }; const commandNotes = (() => { if (!resolvedInstructionsFilePath) return [] as string[]; @@ -348,6 +357,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) return "exists"; - await sql.unsafe(`create database "${databaseName}"`); + await sql.unsafe(`create database "${databaseName}" encoding 'UTF8' lc_collate 'C' lc_ctype 'C' template template0`); return "created"; } finally { await sql.end(); diff --git a/packages/db/src/migration-runtime.ts b/packages/db/src/migration-runtime.ts index bc90b762..e07bdf04 100644 --- a/packages/db/src/migration-runtime.ts +++ b/packages/db/src/migration-runtime.ts @@ -17,6 +17,7 @@ type EmbeddedPostgresCtor = new (opts: { password: string; port: number; persistent: boolean; + initdbFlags?: string[]; onLog?: (message: unknown) => void; onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; @@ -96,6 +97,7 @@ async function ensureEmbeddedPostgresConnection( password: "paperclip", port: preferredPort, persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C"], onLog: () => {}, onError: () => {}, }); diff --git a/packages/db/src/migrations/0028_harsh_goliath.sql b/packages/db/src/migrations/0028_harsh_goliath.sql new file mode 100644 index 00000000..b92ad944 --- /dev/null +++ b/packages/db/src/migrations/0028_harsh_goliath.sql @@ -0,0 +1,54 @@ +CREATE TABLE "document_revisions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "revision_number" integer NOT NULL, + "body" text NOT NULL, + "change_summary" text, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "title" text, + "format" text DEFAULT 'markdown' NOT NULL, + "latest_body" text NOT NULL, + "latest_revision_id" uuid, + "latest_revision_number" integer DEFAULT 1 NOT NULL, + "created_by_agent_id" uuid, + "created_by_user_id" text, + "updated_by_agent_id" uuid, + "updated_by_user_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "issue_documents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "document_id" uuid NOT NULL, + "key" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "documents" ADD CONSTRAINT "documents_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "document_revisions_document_revision_uq" ON "document_revisions" USING btree ("document_id","revision_number");--> statement-breakpoint +CREATE INDEX "document_revisions_company_document_created_idx" ON "document_revisions" USING btree ("company_id","document_id","created_at");--> statement-breakpoint +CREATE INDEX "documents_company_updated_idx" ON "documents" USING btree ("company_id","updated_at");--> statement-breakpoint +CREATE INDEX "documents_company_created_idx" ON "documents" USING btree ("company_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "issue_documents_company_issue_key_uq" ON "issue_documents" USING btree ("company_id","issue_id","key");--> statement-breakpoint +CREATE UNIQUE INDEX "issue_documents_document_uq" ON "issue_documents" USING btree ("document_id");--> statement-breakpoint +CREATE INDEX "issue_documents_company_issue_updated_idx" ON "issue_documents" USING btree ("company_id","issue_id","updated_at"); \ No newline at end of file diff --git a/packages/db/src/migrations/0029_plugin_tables.sql b/packages/db/src/migrations/0029_plugin_tables.sql new file mode 100644 index 00000000..8ee0d937 --- /dev/null +++ b/packages/db/src/migrations/0029_plugin_tables.sql @@ -0,0 +1,177 @@ +-- Rollback: +-- DROP INDEX IF EXISTS "plugin_logs_level_idx"; +-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx"; +-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq"; +-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx"; +-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx"; +-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_external_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_scope_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_type_idx"; +-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx"; +-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx"; +-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx"; +-- DROP INDEX IF EXISTS "plugins_status_idx"; +-- DROP INDEX IF EXISTS "plugins_plugin_key_idx"; +-- DROP TABLE IF EXISTS "plugin_logs"; +-- DROP TABLE IF EXISTS "plugin_company_settings"; +-- DROP TABLE IF EXISTS "plugin_webhook_deliveries"; +-- DROP TABLE IF EXISTS "plugin_job_runs"; +-- DROP TABLE IF EXISTS "plugin_jobs"; +-- DROP TABLE IF EXISTS "plugin_entities"; +-- DROP TABLE IF EXISTS "plugin_state"; +-- DROP TABLE IF EXISTS "plugin_config"; +-- DROP TABLE IF EXISTS "plugins"; + +CREATE TABLE "plugins" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_key" text NOT NULL, + "package_name" text NOT NULL, + "package_path" text, + "version" text NOT NULL, + "api_version" integer DEFAULT 1 NOT NULL, + "categories" jsonb DEFAULT '[]'::jsonb NOT NULL, + "manifest_json" jsonb NOT NULL, + "status" text DEFAULT 'installed' NOT NULL, + "install_order" integer, + "last_error" text, + "installed_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_config" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "config_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_state" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "scope_kind" text NOT NULL, + "scope_id" text, + "namespace" text DEFAULT 'default' NOT NULL, + "state_key" text NOT NULL, + "value_json" jsonb NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key") +); +--> statement-breakpoint +CREATE TABLE "plugin_entities" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "entity_type" text NOT NULL, + "scope_kind" text NOT NULL, + "scope_id" text, + "external_id" text, + "title" text, + "status" text, + "data" 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 +CREATE TABLE "plugin_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "job_key" text NOT NULL, + "schedule" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "last_run_at" timestamp with time zone, + "next_run_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_job_runs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "job_id" uuid NOT NULL, + "plugin_id" uuid NOT NULL, + "trigger" text NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "duration_ms" integer, + "error" text, + "logs" jsonb DEFAULT '[]'::jsonb NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_webhook_deliveries" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "plugin_id" uuid NOT NULL, + "webhook_key" text NOT NULL, + "external_id" text, + "status" text DEFAULT 'pending' NOT NULL, + "duration_ms" integer, + "error" text, + "payload" jsonb NOT NULL, + "headers" jsonb DEFAULT '{}'::jsonb NOT NULL, + "started_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_company_settings" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "plugin_id" uuid NOT NULL, + "settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "enabled" boolean DEFAULT true NOT NULL +); +--> statement-breakpoint +CREATE TABLE "plugin_logs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "plugin_id" uuid NOT NULL, + "level" text NOT NULL DEFAULT 'info', + "message" text NOT NULL, + "meta" jsonb, + "created_at" timestamp with time zone NOT NULL DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint +CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint +CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint +CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint +CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint +CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint +CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint +CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint +CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint +CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint +CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level"); diff --git a/packages/db/src/migrations/meta/0028_snapshot.json b/packages/db/src/migrations/meta/0028_snapshot.json new file mode 100644 index 00000000..122f75ef --- /dev/null +++ b/packages/db/src/migrations/meta/0028_snapshot.json @@ -0,0 +1,6710 @@ +{ + "id": "6fe59d88-aadc-4acb-acf4-ea60b7dbc7dc", + "prevId": "8186209d-f7ec-4048-bd4f-c96530f45304", + "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" + }, + "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 + }, + "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": {} + } + }, + "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" + } + }, + "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.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'" + }, + "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 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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_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.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 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "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_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" + } + }, + "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.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_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 + }, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "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": {} + } + }, + "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" + } + }, + "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_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.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 + }, + "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 + }, + "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_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_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_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" + } + }, + "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": {} + } + }, + "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.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 + }, + "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 + }, + "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": {} + } + }, + "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 + }, + "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.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 + }, + "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_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_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0029_snapshot.json b/packages/db/src/migrations/meta/0029_snapshot.json new file mode 100644 index 00000000..e5a4f636 --- /dev/null +++ b/packages/db/src/migrations/meta/0029_snapshot.json @@ -0,0 +1,7899 @@ +{ + "id": "fdb36f4e-6463-497d-b704-22d33be9b450", + "prevId": "6fe59d88-aadc-4acb-acf4-ea60b7dbc7dc", + "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" + }, + "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 + }, + "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": {} + } + }, + "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" + } + }, + "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.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'" + }, + "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 + }, + "require_board_approval_for_new_agents": { + "name": "require_board_approval_for_new_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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_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.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 + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "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_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" + } + }, + "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.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_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 + }, + "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": {} + } + }, + "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" + } + }, + "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 + }, + "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": {} + } + }, + "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" + } + }, + "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_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.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 + }, + "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 + }, + "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_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_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "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_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" + } + }, + "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": {} + } + }, + "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_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_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 + }, + "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 + }, + "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": {} + } + }, + "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 + }, + "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.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 + }, + "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_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_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 80a1dfbd..b061a56a 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -197,6 +197,20 @@ "when": 1773150731736, "tag": "0027_tranquil_tenebrous", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1773432085646, + "tag": "0028_harsh_goliath", + "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1773417600000, + "tag": "0029_plugin_tables", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/runtime-config.test.ts b/packages/db/src/runtime-config.test.ts index 55371e09..4627e691 100644 --- a/packages/db/src/runtime-config.test.ts +++ b/packages/db/src/runtime-config.test.ts @@ -46,6 +46,7 @@ describe("resolveDatabaseTarget", () => { const projectDir = path.join(tempDir, "repo"); fs.mkdirSync(projectDir, { recursive: true }); process.chdir(projectDir); + delete process.env.PAPERCLIP_CONFIG; writeJson(path.join(projectDir, ".paperclip", "config.json"), { database: { mode: "embedded-postgres", embeddedPostgresPort: 54329 }, }); diff --git a/packages/db/src/schema/document_revisions.ts b/packages/db/src/schema/document_revisions.ts new file mode 100644 index 00000000..6e739989 --- /dev/null +++ b/packages/db/src/schema/document_revisions.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { agents } from "./agents.js"; +import { documents } from "./documents.js"; + +export const documentRevisions = pgTable( + "document_revisions", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + revisionNumber: integer("revision_number").notNull(), + body: text("body").notNull(), + changeSummary: text("change_summary"), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + documentRevisionUq: uniqueIndex("document_revisions_document_revision_uq").on( + table.documentId, + table.revisionNumber, + ), + companyDocumentCreatedIdx: index("document_revisions_company_document_created_idx").on( + table.companyId, + table.documentId, + table.createdAt, + ), + }), +); diff --git a/packages/db/src/schema/documents.ts b/packages/db/src/schema/documents.ts new file mode 100644 index 00000000..53d5f358 --- /dev/null +++ b/packages/db/src/schema/documents.ts @@ -0,0 +1,26 @@ +import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { agents } from "./agents.js"; + +export const documents = pgTable( + "documents", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + title: text("title"), + format: text("format").notNull().default("markdown"), + latestBody: text("latest_body").notNull(), + latestRevisionId: uuid("latest_revision_id"), + latestRevisionNumber: integer("latest_revision_number").notNull().default(1), + createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + createdByUserId: text("created_by_user_id"), + updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }), + updatedByUserId: text("updated_by_user_id"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt), + companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt), + }), +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 3416ea9a..f173db45 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -24,6 +24,9 @@ export { issueComments } from "./issue_comments.js"; export { issueReadStates } from "./issue_read_states.js"; export { assets } from "./assets.js"; export { issueAttachments } from "./issue_attachments.js"; +export { documents } from "./documents.js"; +export { documentRevisions } from "./document_revisions.js"; +export { issueDocuments } from "./issue_documents.js"; export { heartbeatRuns } from "./heartbeat_runs.js"; export { heartbeatRunEvents } from "./heartbeat_run_events.js"; export { costEvents } from "./cost_events.js"; @@ -32,3 +35,11 @@ export { approvalComments } from "./approval_comments.js"; export { activityLog } from "./activity_log.js"; export { companySecrets } from "./company_secrets.js"; export { companySecretVersions } from "./company_secret_versions.js"; +export { plugins } from "./plugins.js"; +export { pluginConfig } from "./plugin_config.js"; +export { pluginCompanySettings } from "./plugin_company_settings.js"; +export { pluginState } from "./plugin_state.js"; +export { pluginEntities } from "./plugin_entities.js"; +export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js"; +export { pluginWebhookDeliveries } from "./plugin_webhooks.js"; +export { pluginLogs } from "./plugin_logs.js"; diff --git a/packages/db/src/schema/issue_documents.ts b/packages/db/src/schema/issue_documents.ts new file mode 100644 index 00000000..b015f8e5 --- /dev/null +++ b/packages/db/src/schema/issue_documents.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; +import { documents } from "./documents.js"; + +export const issueDocuments = pgTable( + "issue_documents", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }), + documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }), + key: text("key").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIssueKeyUq: uniqueIndex("issue_documents_company_issue_key_uq").on( + table.companyId, + table.issueId, + table.key, + ), + documentUq: uniqueIndex("issue_documents_document_uq").on(table.documentId), + companyIssueUpdatedIdx: index("issue_documents_company_issue_updated_idx").on( + table.companyId, + table.issueId, + table.updatedAt, + ), + }), +); diff --git a/packages/db/src/schema/plugin_company_settings.ts b/packages/db/src/schema/plugin_company_settings.ts new file mode 100644 index 00000000..87d4b4af --- /dev/null +++ b/packages/db/src/schema/plugin_company_settings.ts @@ -0,0 +1,41 @@ +import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_company_settings` table — stores operator-managed plugin settings + * scoped to a specific company. + * + * This is distinct from `plugin_config`, which stores instance-wide plugin + * configuration. Each company can have at most one settings row per plugin. + * + * Rows represent explicit overrides from the default company behavior: + * - no row => plugin is enabled for the company by default + * - row with `enabled = false` => plugin is disabled for that company + * - row with `enabled = true` => plugin remains enabled and stores company settings + */ +export const pluginCompanySettings = pgTable( + "plugin_company_settings", + { + 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" }), + enabled: boolean("enabled").notNull().default(true), + settingsJson: jsonb("settings_json").$type>().notNull().default({}), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIdx: index("plugin_company_settings_company_idx").on(table.companyId), + pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId), + companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on( + table.companyId, + table.pluginId, + ), + }), +); diff --git a/packages/db/src/schema/plugin_config.ts b/packages/db/src/schema/plugin_config.ts new file mode 100644 index 00000000..24407b97 --- /dev/null +++ b/packages/db/src/schema/plugin_config.ts @@ -0,0 +1,30 @@ +import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_config` table — stores operator-provided instance configuration + * for each plugin (one row per plugin, enforced by a unique index on + * `plugin_id`). + * + * The `config_json` column holds the values that the operator enters in the + * plugin settings UI. These values are validated at runtime against the + * plugin's `instanceConfigSchema` from the manifest. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const pluginConfig = pgTable( + "plugin_config", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + configJson: jsonb("config_json").$type>().notNull().default({}), + lastError: text("last_error"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId), + }), +); diff --git a/packages/db/src/schema/plugin_entities.ts b/packages/db/src/schema/plugin_entities.ts new file mode 100644 index 00000000..5f732304 --- /dev/null +++ b/packages/db/src/schema/plugin_entities.ts @@ -0,0 +1,54 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginStateScopeKind } from "@paperclipai/shared"; + +/** + * `plugin_entities` table — persistent high-level mapping between Paperclip + * objects and external plugin-defined entities. + * + * This table is used by plugins (e.g. `linear`, `github`) to store pointers + * to their respective external IDs for projects, issues, etc. and to store + * their custom data. + * + * Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities` + * is intended for structured object mappings that the host can understand + * and query for cross-plugin UI integration. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const pluginEntities = pgTable( + "plugin_entities", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + scopeKind: text("scope_kind").$type().notNull(), + scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id) + externalId: text("external_id"), // ID in the external system + title: text("title"), + status: text("status"), + data: jsonb("data").$type>().notNull().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId), + typeIdx: index("plugin_entities_type_idx").on(table.entityType), + scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId), + externalIdx: uniqueIndex("plugin_entities_external_idx").on( + table.pluginId, + table.entityType, + table.externalId, + ), + }), +); diff --git a/packages/db/src/schema/plugin_jobs.ts b/packages/db/src/schema/plugin_jobs.ts new file mode 100644 index 00000000..fec0d0c4 --- /dev/null +++ b/packages/db/src/schema/plugin_jobs.ts @@ -0,0 +1,102 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared"; + +/** + * `plugin_jobs` table — registration and runtime configuration for + * scheduled jobs declared by plugins in their manifests. + * + * Each row represents one scheduled job entry for a plugin. The + * `job_key` matches the key declared in the manifest's `jobs` array. + * The `schedule` column stores the cron expression or interval string + * used by the job scheduler to decide when to fire the job. + * + * Status values: + * - `active` — job is enabled and will run on schedule + * - `paused` — job is temporarily disabled by the operator + * - `error` — job has been disabled due to repeated failures + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` + */ +export const pluginJobs = pgTable( + "plugin_jobs", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Identifier matching the key in the plugin manifest's `jobs` array. */ + jobKey: text("job_key").notNull(), + /** Cron expression (e.g. `"0 * * * *"`) or interval string. */ + schedule: text("schedule").notNull(), + /** Current scheduling state. */ + status: text("status").$type().notNull().default("active"), + /** Timestamp of the most recent successful execution. */ + lastRunAt: timestamp("last_run_at", { withTimezone: true }), + /** Pre-computed timestamp of the next scheduled execution. */ + nextRunAt: timestamp("next_run_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId), + nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt), + uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey), + }), +); + +/** + * `plugin_job_runs` table — immutable execution history for plugin-owned jobs. + * + * Each row is created when a job run begins and updated when it completes. + * Rows are never modified after `status` reaches a terminal value + * (`succeeded` | `failed` | `cancelled`). + * + * Trigger values: + * - `scheduled` — fired automatically by the cron/interval scheduler + * - `manual` — triggered by an operator via the admin UI or API + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs` + */ +export const pluginJobRuns = pgTable( + "plugin_job_runs", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the parent job definition. Cascades on delete. */ + jobId: uuid("job_id") + .notNull() + .references(() => pluginJobs.id, { onDelete: "cascade" }), + /** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** What caused this run to start (`"scheduled"` or `"manual"`). */ + trigger: text("trigger").$type().notNull(), + /** Current lifecycle state of this run. */ + status: text("status").$type().notNull().default("pending"), + /** Wall-clock duration in milliseconds. Null until the run finishes. */ + durationMs: integer("duration_ms"), + /** Error message if `status === "failed"`. */ + error: text("error"), + /** Ordered list of log lines emitted during this run. */ + logs: jsonb("logs").$type().notNull().default([]), + startedAt: timestamp("started_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + jobIdx: index("plugin_job_runs_job_idx").on(table.jobId), + pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId), + statusIdx: index("plugin_job_runs_status_idx").on(table.status), + }), +); diff --git a/packages/db/src/schema/plugin_logs.ts b/packages/db/src/schema/plugin_logs.ts new file mode 100644 index 00000000..d32908f1 --- /dev/null +++ b/packages/db/src/schema/plugin_logs.ts @@ -0,0 +1,43 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_logs` table — structured log storage for plugin workers. + * + * Each row stores a single log entry emitted by a plugin worker via + * `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and + * time range to support the operator logs panel and debugging workflows. + * + * Rows are inserted by the host when handling `log` notifications from + * the worker process. A capped retention policy can be applied via + * periodic cleanup (e.g. delete rows older than 7 days). + * + * @see PLUGIN_SPEC.md §26 — Observability + */ +export const pluginLogs = pgTable( + "plugin_logs", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + level: text("level").notNull().default("info"), + message: text("message").notNull(), + meta: jsonb("meta").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginTimeIdx: index("plugin_logs_plugin_time_idx").on( + table.pluginId, + table.createdAt, + ), + levelIdx: index("plugin_logs_level_idx").on(table.level), + }), +); diff --git a/packages/db/src/schema/plugin_state.ts b/packages/db/src/schema/plugin_state.ts new file mode 100644 index 00000000..600797fa --- /dev/null +++ b/packages/db/src/schema/plugin_state.ts @@ -0,0 +1,90 @@ +import { + pgTable, + uuid, + text, + timestamp, + jsonb, + index, + unique, +} from "drizzle-orm/pg-core"; +import type { PluginStateScopeKind } from "@paperclipai/shared"; +import { plugins } from "./plugins.js"; + +/** + * `plugin_state` table — scoped key-value storage for plugin workers. + * + * Each row stores a single JSON value identified by + * `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use + * this table through `ctx.state.get()`, `ctx.state.set()`, and + * `ctx.state.delete()` in the SDK. + * + * Scope kinds determine the granularity of isolation: + * - `instance` — one value shared across the whole Paperclip instance + * - `company` — one value per company + * - `project` — one value per project + * - `project_workspace` — one value per project workspace + * - `agent` — one value per agent + * - `issue` — one value per issue + * - `goal` — one value per goal + * - `run` — one value per agent run + * + * The `namespace` column defaults to `"default"` and can be used to + * logically group keys without polluting the root namespace. + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_state` + */ +export const pluginState = pgTable( + "plugin_state", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */ + scopeKind: text("scope_kind").$type().notNull(), + /** + * UUID or text identifier for the scoped object. + * Null for `instance` scope (which has no associated entity). + */ + scopeId: text("scope_id"), + /** + * Sub-namespace to avoid key collisions within a scope. + * Defaults to `"default"` if the plugin does not specify one. + */ + namespace: text("namespace").notNull().default("default"), + /** The key identifying this state entry within the namespace. */ + stateKey: text("state_key").notNull(), + /** JSON-serializable value stored by the plugin. */ + valueJson: jsonb("value_json").notNull(), + /** Timestamp of the most recent write. */ + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + /** + * Unique constraint enforces that there is at most one value per + * (plugin, scope kind, scope id, namespace, key) tuple. + * + * `nullsNotDistinct()` is required so that `scope_id IS NULL` entries + * (used by `instance` scope) are treated as equal by PostgreSQL rather + * than as distinct nulls — otherwise the upsert target in `set()` would + * fail to match existing rows and create duplicates. + * + * Requires PostgreSQL 15+. + */ + uniqueEntry: unique("plugin_state_unique_entry_idx") + .on( + table.pluginId, + table.scopeKind, + table.scopeId, + table.namespace, + table.stateKey, + ) + .nullsNotDistinct(), + /** Speed up lookups by plugin + scope kind (most common access pattern). */ + pluginScopeIdx: index("plugin_state_plugin_scope_idx").on( + table.pluginId, + table.scopeKind, + ), + }), +); diff --git a/packages/db/src/schema/plugin_webhooks.ts b/packages/db/src/schema/plugin_webhooks.ts new file mode 100644 index 00000000..0580e970 --- /dev/null +++ b/packages/db/src/schema/plugin_webhooks.ts @@ -0,0 +1,65 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, +} from "drizzle-orm/pg-core"; +import { plugins } from "./plugins.js"; +import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared"; + +/** + * `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins. + * + * When an external system sends an HTTP POST to a plugin's registered webhook + * endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server + * creates a row in this table before dispatching the payload to the plugin + * worker. This provides an auditable log of every delivery attempt. + * + * The `webhook_key` matches the key declared in the plugin manifest's + * `webhooks` array. `external_id` is an optional identifier supplied by the + * remote system (e.g. a GitHub delivery GUID) that can be used to detect + * and reject duplicate deliveries. + * + * Status values: + * - `pending` — received but not yet dispatched to the worker + * - `processing` — currently being handled by the plugin worker + * - `succeeded` — worker processed the payload successfully + * - `failed` — worker returned an error or timed out + * + * @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries` + */ +export const pluginWebhookDeliveries = pgTable( + "plugin_webhook_deliveries", + { + id: uuid("id").primaryKey().defaultRandom(), + /** FK to the owning plugin. Cascades on delete. */ + pluginId: uuid("plugin_id") + .notNull() + .references(() => plugins.id, { onDelete: "cascade" }), + /** Identifier matching the key in the plugin manifest's `webhooks` array. */ + webhookKey: text("webhook_key").notNull(), + /** Optional de-duplication ID provided by the external system. */ + externalId: text("external_id"), + /** Current delivery state. */ + status: text("status").$type().notNull().default("pending"), + /** Wall-clock processing duration in milliseconds. Null until delivery finishes. */ + durationMs: integer("duration_ms"), + /** Error message if `status === "failed"`. */ + error: text("error"), + /** Raw JSON body of the inbound HTTP request. */ + payload: jsonb("payload").$type>().notNull(), + /** Relevant HTTP headers from the inbound request (e.g. signature headers). */ + headers: jsonb("headers").$type>().notNull().default({}), + startedAt: timestamp("started_at", { withTimezone: true }), + finishedAt: timestamp("finished_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId), + statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status), + keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey), + }), +); diff --git a/packages/db/src/schema/plugins.ts b/packages/db/src/schema/plugins.ts new file mode 100644 index 00000000..948e5d60 --- /dev/null +++ b/packages/db/src/schema/plugins.ts @@ -0,0 +1,45 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + jsonb, + index, + uniqueIndex, +} from "drizzle-orm/pg-core"; +import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared"; + +/** + * `plugins` table — stores one row per installed plugin. + * + * Each plugin is uniquely identified by `plugin_key` (derived from + * the manifest `id`). The full manifest is persisted as JSONB in + * `manifest_json` so the host can reconstruct capability and UI + * slot information without loading the plugin package. + * + * @see PLUGIN_SPEC.md §21.3 + */ +export const plugins = pgTable( + "plugins", + { + id: uuid("id").primaryKey().defaultRandom(), + pluginKey: text("plugin_key").notNull(), + packageName: text("package_name").notNull(), + version: text("version").notNull(), + apiVersion: integer("api_version").notNull().default(1), + categories: jsonb("categories").$type().notNull().default([]), + manifestJson: jsonb("manifest_json").$type().notNull(), + status: text("status").$type().notNull().default("installed"), + installOrder: integer("install_order"), + /** Resolved package path for local-path installs; used to find worker entrypoint. */ + packagePath: text("package_path"), + lastError: text("last_error"), + installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey), + statusIdx: index("plugins_status_idx").on(table.status), + }), +); diff --git a/packages/plugins/create-paperclip-plugin/README.md b/packages/plugins/create-paperclip-plugin/README.md new file mode 100644 index 00000000..24294122 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/README.md @@ -0,0 +1,52 @@ +# @paperclipai/create-paperclip-plugin + +Scaffolding tool for creating new Paperclip plugins. + +```bash +npx @paperclipai/create-paperclip-plugin my-plugin +``` + +Or with options: + +```bash +npx @paperclipai/create-paperclip-plugin @acme/my-plugin \ + --template connector \ + --category connector \ + --display-name "Acme Connector" \ + --description "Syncs Acme data into Paperclip" \ + --author "Acme Inc" +``` + +Supported templates: `default`, `connector`, `workspace` +Supported categories: `connector`, `workspace`, `automation`, `ui` + +Generates: +- typed manifest + worker entrypoint +- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks +- test file using `@paperclipai/plugin-sdk/testing` +- `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. + +Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`. + +Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly: + +```bash +node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \ + --output /absolute/path/to/plugins \ + --sdk-path /absolute/path/to/paperclip/packages/plugins/sdk +``` + +That gives you an outside-repo local development path before the SDK is published to npm. + +## Workflow after scaffolding + +```bash +cd my-plugin +pnpm install +pnpm dev # watch worker + manifest + ui bundles +pnpm dev:ui # local UI preview server with hot-reload events +pnpm test +``` diff --git a/packages/plugins/create-paperclip-plugin/package.json b/packages/plugins/create-paperclip-plugin/package.json new file mode 100644 index 00000000..e863cd6c --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/package.json @@ -0,0 +1,40 @@ +{ + "name": "@paperclipai/create-paperclip-plugin", + "version": "0.1.0", + "type": "module", + "bin": { + "create-paperclip-plugin": "./dist/index.js" + }, + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "access": "public", + "bin": { + "create-paperclip-plugin": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "typescript": "^5.7.3" + } +} diff --git a/packages/plugins/create-paperclip-plugin/src/index.ts b/packages/plugins/create-paperclip-plugin/src/index.ts new file mode 100644 index 00000000..d5aec878 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/src/index.ts @@ -0,0 +1,496 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const VALID_TEMPLATES = ["default", "connector", "workspace"] as const; +type PluginTemplate = (typeof VALID_TEMPLATES)[number]; +const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const); + +export interface ScaffoldPluginOptions { + pluginName: string; + outputDir: string; + template?: PluginTemplate; + displayName?: string; + description?: string; + author?: string; + category?: "connector" | "workspace" | "automation" | "ui"; + sdkPath?: string; +} + +/** Validate npm-style plugin package names (scoped or unscoped). */ +export function isValidPluginName(name: string): boolean { + const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/; + const unscopedPattern = /^[a-z0-9._-]+$/; + return scopedPattern.test(name) || unscopedPattern.test(name); +} + +/** Convert `@scope/name` to an output directory basename (`name`). */ +function packageToDirName(pluginName: string): string { + return pluginName.replace(/^@[^/]+\//, ""); +} + +/** Convert an npm package name into a manifest-safe plugin id. */ +function packageToManifestId(pluginName: string): string { + if (!pluginName.startsWith("@")) { + return pluginName; + } + + return pluginName.slice(1).replace("/", "."); +} + +/** Build a human-readable display name from package name tokens. */ +function makeDisplayName(pluginName: string): string { + const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim(); + return raw + .split(/\s+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function writeFile(target: string, content: string) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +function quote(value: string): string { + return JSON.stringify(value); +} + +function toPosixPath(value: string): string { + return value.split(path.sep).join("/"); +} + +function formatFileDependency(absPath: string): string { + return `file:${toPosixPath(path.resolve(absPath))}`; +} + +function getLocalSdkPackagePath(): string { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk"); +} + +function getRepoRootFromSdkPath(sdkPath: string): string { + return path.resolve(sdkPath, "..", "..", ".."); +} + +function getLocalSharedPackagePath(sdkPath: string): string { + return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared"); +} + +function isInsideDir(targetPath: string, parentPath: string): boolean { + const relative = path.relative(parentPath, targetPath); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function packLocalPackage(packagePath: string, outputDir: string): string { + const packageJsonPath = path.join(packagePath, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Package package.json not found at ${packageJsonPath}`); + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + name?: string; + version?: string; + }; + const packageName = packageJson.name ?? path.basename(packagePath); + const packageVersion = packageJson.version ?? "0.0.0"; + const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`; + const sdkBundleDir = path.join(outputDir, ".paperclip-sdk"); + + fs.mkdirSync(sdkBundleDir, { recursive: true }); + execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" }); + execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" }); + + const tarballPath = path.join(sdkBundleDir, tarballFileName); + if (!fs.existsSync(tarballPath)) { + throw new Error(`Packed tarball was not created at ${tarballPath}`); + } + + return tarballPath; +} + +/** + * Generate a complete Paperclip plugin starter project. + * + * Output includes manifest/worker/UI entries, SDK harness tests, bundler presets, + * and a local dev server script for hot-reload workflow. + */ +export function scaffoldPluginProject(options: ScaffoldPluginOptions): string { + const template = options.template ?? "default"; + if (!VALID_TEMPLATES.includes(template)) { + throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`); + } + + if (!isValidPluginName(options.pluginName)) { + throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens."); + } + + if (options.category && !VALID_CATEGORIES.has(options.category)) { + throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`); + } + + const outputDir = path.resolve(options.outputDir); + if (fs.existsSync(outputDir)) { + throw new Error(`Directory already exists: ${outputDir}`); + } + + const displayName = options.displayName ?? makeDisplayName(options.pluginName); + const description = options.description ?? "A Paperclip plugin"; + const author = options.author ?? "Plugin Author"; + const category = options.category ?? (template === "workspace" ? "workspace" : "connector"); + const manifestId = packageToManifestId(options.pluginName); + const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath()); + const localSharedPath = getLocalSharedPackagePath(localSdkPath); + const repoRoot = getRepoRootFromSdkPath(localSdkPath); + const useWorkspaceSdk = isInsideDir(outputDir, repoRoot); + + fs.mkdirSync(outputDir, { recursive: true }); + + const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir); + const sdkDependency = useWorkspaceSdk + ? "workspace:*" + : `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`; + + const packageJson = { + name: options.pluginName, + version: "0.1.0", + type: "module", + private: true, + description, + scripts: { + build: "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + dev: "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", + test: "vitest run --config ./vitest.config.ts", + typecheck: "tsc --noEmit" + }, + paperclipPlugin: { + manifest: "./dist/manifest.js", + worker: "./dist/worker.js", + ui: "./dist/ui/" + }, + keywords: ["paperclip", "plugin", category], + author, + license: "MIT", + ...(packedSharedTarball + ? { + pnpm: { + overrides: { + "@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`, + }, + }, + } + : {}), + devDependencies: { + ...(packedSharedTarball + ? { + "@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`, + } + : {}), + "@paperclipai/plugin-sdk": sdkDependency, + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + esbuild: "^0.27.3", + rollup: "^4.38.0", + tslib: "^2.8.1", + typescript: "^5.7.3", + vitest: "^3.0.5" + }, + peerDependencies: { + react: ">=18" + } + }; + + writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`); + + const tsconfig = { + compilerOptions: { + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + lib: ["ES2022", "DOM"], + jsx: "react-jsx", + strict: true, + skipLibCheck: true, + declaration: true, + declarationMap: true, + sourceMap: true, + outDir: "dist", + rootDir: "." + }, + include: ["src", "tests"], + exclude: ["dist", "node_modules"] + }; + + writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`); + + writeFile( + path.join(outputDir, "esbuild.config.mjs"), + `import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} +`, + ); + + writeFile( + path.join(outputDir, "rollup.config.mjs"), + `import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); +`, + ); + + writeFile( + path.join(outputDir, "vitest.config.ts"), + `import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); +`, + ); + + writeFile( + path.join(outputDir, "src", "manifest.ts"), + `import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: ${quote(manifestId)}, + apiVersion: 1, + version: "0.1.0", + displayName: ${quote(displayName)}, + description: ${quote(description)}, + author: ${quote(author)}, + categories: [${quote(category)}], + capabilities: [ + "events.subscribe", + "plugin.state.read", + "plugin.state.write" + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui" + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "health-widget", + displayName: ${quote(`${displayName} Health`)}, + exportName: "DashboardWidget" + } + ] + } +}; + +export default manifest; +`, + ); + + writeFile( + path.join(outputDir, "src", "worker.ts"), + `import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.events.on("issue.created", async (event) => { + const issueId = event.entityId ?? "unknown"; + await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true); + ctx.logger.info("Observed issue.created", { issueId }); + }); + + ctx.data.register("health", async () => { + return { status: "ok", checkedAt: new Date().toISOString() }; + }); + + ctx.actions.register("ping", async () => { + ctx.logger.info("Ping action invoked"); + return { pong: true, at: new Date().toISOString() }; + }); + }, + + async onHealth() { + return { status: "ok", message: "Plugin worker is running" }; + } +}); + +export default plugin; +runWorker(plugin, import.meta.url); +`, + ); + + writeFile( + path.join(outputDir, "src", "ui", "index.tsx"), + `import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +type HealthData = { + status: "ok" | "degraded" | "error"; + checkedAt: string; +}; + +export function DashboardWidget(_props: PluginWidgetProps) { + const { data, loading, error } = usePluginData("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return
Plugin error: {error.message}
; + + return ( +
+ ${displayName} +
Health: {data?.status ?? "unknown"}
+
Checked: {data?.checkedAt ?? "never"}
+ +
+ ); +} +`, + ); + + writeFile( + path.join(outputDir, "tests", "plugin.spec.ts"), + `import { describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +describe("plugin scaffold", () => { + it("registers data + actions and handles events", async () => { + const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] }); + await plugin.definition.setup(harness.ctx); + + await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); + expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true); + + const data = await harness.getData<{ status: string }>("health"); + expect(data.status).toBe("ok"); + + const action = await harness.performAction<{ pong: boolean }>("ping"); + expect(action.pong).toBe(true); + }); +}); +`, + ); + + writeFile( + path.join(outputDir, "README.md"), + `# ${displayName} + +${description} + +## Development + +\`\`\`bash +pnpm install +pnpm dev # watch builds +pnpm dev:ui # local dev server with hot-reload events +pnpm test +\`\`\` + +${sdkDependency.startsWith("file:") + ? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n` + : ""} + +## Install Into Paperclip + +\`\`\`bash +curl -X POST http://127.0.0.1:3100/api/plugins/install \\ + -H "Content-Type: application/json" \\ + -d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}' +\`\`\` + +## Build Options + +- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`. +- \`pnpm build:rollup\` uses rollup presets from the same SDK. +`, + ); + + writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n"); + + return outputDir; +} + +function parseArg(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index === -1) return undefined; + return process.argv[index + 1]; +} + +/** CLI wrapper for `scaffoldPluginProject`. */ +function runCli() { + const pluginName = process.argv[2]; + if (!pluginName) { + // eslint-disable-next-line no-console + console.error("Usage: create-paperclip-plugin [--template default|connector|workspace] [--output ] [--sdk-path ]"); + process.exit(1); + } + + const template = (parseArg("--template") ?? "default") as PluginTemplate; + const outputRoot = parseArg("--output") ?? process.cwd(); + const targetDir = path.resolve(outputRoot, packageToDirName(pluginName)); + + const out = scaffoldPluginProject({ + pluginName, + outputDir: targetDir, + template, + displayName: parseArg("--display-name"), + description: parseArg("--description"), + author: parseArg("--author"), + category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined, + sdkPath: parseArg("--sdk-path"), + }); + + // eslint-disable-next-line no-console + console.log(`Created plugin scaffold at ${out}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + runCli(); +} diff --git a/packages/plugins/create-paperclip-plugin/tsconfig.json b/packages/plugins/create-paperclip-plugin/tsconfig.json new file mode 100644 index 00000000..90314411 --- /dev/null +++ b/packages/plugins/create-paperclip-plugin/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore b/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore new file mode 100644 index 00000000..de4d1f00 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/README.md b/packages/plugins/examples/plugin-authoring-smoke-example/README.md new file mode 100644 index 00000000..50099ad4 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/README.md @@ -0,0 +1,23 @@ +# Plugin Authoring Smoke Example + +A Paperclip plugin + +## Development + +```bash +pnpm install +pnpm dev # watch builds +pnpm dev:ui # local dev server with hot-reload events +pnpm test +``` + +## Install Into Paperclip + +```bash +pnpm paperclipai plugin install ./ +``` + +## Build Options + +- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`. +- `pnpm build:rollup` uses rollup presets from the same SDK. diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs b/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs new file mode 100644 index 00000000..b5cfd36e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/esbuild.config.mjs @@ -0,0 +1,17 @@ +import esbuild from "esbuild"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); +const watch = process.argv.includes("--watch"); + +const workerCtx = await esbuild.context(presets.esbuild.worker); +const manifestCtx = await esbuild.context(presets.esbuild.manifest); +const uiCtx = await esbuild.context(presets.esbuild.ui); + +if (watch) { + await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]); + console.log("esbuild watch mode enabled for worker, manifest, and ui"); +} else { + await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]); + await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]); +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/package.json b/packages/plugins/examples/plugin-authoring-smoke-example/package.json new file mode 100644 index 00000000..66657e4a --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/package.json @@ -0,0 +1,45 @@ +{ + "name": "@paperclipai/plugin-authoring-smoke-example", + "version": "0.1.0", + "type": "module", + "private": true, + "description": "A Paperclip plugin", + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "node ./esbuild.config.mjs", + "build:rollup": "rollup -c", + "dev": "node ./esbuild.config.mjs --watch", + "dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177", + "test": "vitest run --config ./vitest.config.ts", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "keywords": [ + "paperclip", + "plugin", + "connector" + ], + "author": "Plugin Author", + "license": "MIT", + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.1", + "@rollup/plugin-typescript": "^12.1.2", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "esbuild": "^0.27.3", + "rollup": "^4.38.0", + "tslib": "^2.8.1", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs b/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs new file mode 100644 index 00000000..ccee40a7 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/rollup.config.mjs @@ -0,0 +1,28 @@ +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import typescript from "@rollup/plugin-typescript"; +import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers"; + +const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" }); + +function withPlugins(config) { + if (!config) return null; + return { + ...config, + plugins: [ + nodeResolve({ + extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"], + }), + typescript({ + tsconfig: "./tsconfig.json", + declaration: false, + declarationMap: false, + }), + ], + }; +} + +export default [ + withPlugins(presets.rollup.manifest), + withPlugins(presets.rollup.worker), + withPlugins(presets.rollup.ui), +].filter(Boolean); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts b/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts new file mode 100644 index 00000000..eb1c1efe --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/manifest.ts @@ -0,0 +1,32 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const manifest: PaperclipPluginManifestV1 = { + id: "paperclipai.plugin-authoring-smoke-example", + apiVersion: 1, + version: "0.1.0", + displayName: "Plugin Authoring Smoke Example", + description: "A Paperclip plugin", + author: "Plugin Author", + categories: ["connector"], + capabilities: [ + "events.subscribe", + "plugin.state.read", + "plugin.state.write" + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui" + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: "health-widget", + displayName: "Plugin Authoring Smoke Example Health", + exportName: "DashboardWidget" + } + ] + } +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx b/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx new file mode 100644 index 00000000..2b0cabeb --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/ui/index.tsx @@ -0,0 +1,23 @@ +import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +type HealthData = { + status: "ok" | "degraded" | "error"; + checkedAt: string; +}; + +export function DashboardWidget(_props: PluginWidgetProps) { + const { data, loading, error } = usePluginData("health"); + const ping = usePluginAction("ping"); + + if (loading) return
Loading plugin health...
; + if (error) return
Plugin error: {error.message}
; + + return ( +
+ Plugin Authoring Smoke Example +
Health: {data?.status ?? "unknown"}
+
Checked: {data?.checkedAt ?? "never"}
+ +
+ ); +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts b/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts new file mode 100644 index 00000000..16ef652e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/src/worker.ts @@ -0,0 +1,27 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const plugin = definePlugin({ + async setup(ctx) { + ctx.events.on("issue.created", async (event) => { + const issueId = event.entityId ?? "unknown"; + await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true); + ctx.logger.info("Observed issue.created", { issueId }); + }); + + ctx.data.register("health", async () => { + return { status: "ok", checkedAt: new Date().toISOString() }; + }); + + ctx.actions.register("ping", async () => { + ctx.logger.info("Ping action invoked"); + return { pong: true, at: new Date().toISOString() }; + }); + }, + + async onHealth() { + return { status: "ok", message: "Plugin worker is running" }; + } +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts b/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts new file mode 100644 index 00000000..8dddda88 --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/tests/plugin.spec.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; +import manifest from "../src/manifest.js"; +import plugin from "../src/worker.js"; + +describe("plugin scaffold", () => { + it("registers data + actions and handles events", async () => { + const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] }); + await plugin.definition.setup(harness.ctx); + + await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" }); + expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true); + + const data = await harness.getData<{ status: string }>("health"); + expect(data.status).toBe("ok"); + + const action = await harness.performAction<{ pong: boolean }>("ping"); + expect(action.pong).toBe(true); + }); +}); diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json b/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json new file mode 100644 index 00000000..a697519e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM" + ], + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "." + }, + "include": [ + "src", + "tests" + ], + "exclude": [ + "dist", + "node_modules" + ] +} diff --git a/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts b/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts new file mode 100644 index 00000000..649a293e --- /dev/null +++ b/packages/plugins/examples/plugin-authoring-smoke-example/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.spec.ts"], + environment: "node", + }, +}); diff --git a/packages/plugins/examples/plugin-file-browser-example/README.md b/packages/plugins/examples/plugin-file-browser-example/README.md new file mode 100644 index 00000000..ca02fcf7 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/README.md @@ -0,0 +1,62 @@ +# File Browser Example Plugin + +Example Paperclip plugin that demonstrates: + +- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off. +- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support. + +This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included. + +## Slots + +| Slot | Type | Description | +|---------------------|---------------------|--------------------------------------------------| +| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. | +| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.| + +## Settings + +- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off. +- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown. + +## Capabilities + +- `ui.sidebar.register` — project sidebar item +- `ui.detailTab.register` — project detail tab +- `projects.read` — resolve project +- `project.workspaces.read` — list workspaces and read paths for file access + +## Worker + +- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first). +- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`). +- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`). +- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk. + +## Local Install (Dev) + +From the repo root, build the plugin and install it by local path: + +```bash +pnpm --filter @paperclipai/plugin-file-browser-example build +pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example +``` + +To uninstall: + +```bash +pnpm paperclipai plugin uninstall paperclip-file-browser-example --force +``` + +**Local development notes:** + +- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists. +- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host. +- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin. +- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config. + +## Structure + +- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`). +- `src/worker.ts` — data handlers for workspaces, file list, file content. +- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor). diff --git a/packages/plugins/examples/plugin-file-browser-example/package.json b/packages/plugins/examples/plugin-file-browser-example/package.json new file mode 100644 index 00000000..86c720d4 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/package.json @@ -0,0 +1,42 @@ +{ + "name": "@paperclipai/plugin-file-browser-example", + "version": "0.1.0", + "description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "tsc && node ./scripts/build-ui.mjs", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "dependencies": { + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/language": "^6.11.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.28.0", + "@lezer/highlight": "^1.2.1", + "@paperclipai/plugin-sdk": "workspace:*", + "codemirror": "^6.0.1" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "esbuild": "^0.27.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs new file mode 100644 index 00000000..5cd75637 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/scripts/build-ui.mjs @@ -0,0 +1,24 @@ +import esbuild from "esbuild"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const packageRoot = path.resolve(__dirname, ".."); + +await esbuild.build({ + entryPoints: [path.join(packageRoot, "src/ui/index.tsx")], + outfile: path.join(packageRoot, "dist/ui/index.js"), + bundle: true, + format: "esm", + platform: "browser", + target: ["es2022"], + sourcemap: true, + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "@paperclipai/plugin-sdk/ui", + ], + logLevel: "info", +}); diff --git a/packages/plugins/examples/plugin-file-browser-example/src/index.ts b/packages/plugins/examples/plugin-file-browser-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts new file mode 100644 index 00000000..027c134b --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/manifest.ts @@ -0,0 +1,85 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +const PLUGIN_ID = "paperclip-file-browser-example"; +const FILES_SIDEBAR_SLOT_ID = "files-link"; +const FILES_TAB_SLOT_ID = "files-tab"; +const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links"; +const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: "0.2.0", + displayName: "File Browser (Example)", + description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.", + author: "Paperclip", + categories: ["workspace", "ui"], + capabilities: [ + "ui.sidebar.register", + "ui.detailTab.register", + "ui.commentAnnotation.register", + "ui.action.register", + "projects.read", + "project.workspaces.read", + "issue.comments.read", + "plugin.state.read", + ], + instanceConfigSchema: { + type: "object", + properties: { + showFilesInSidebar: { + type: "boolean", + title: "Show Files in Sidebar", + default: false, + description: "Adds the Files link under each project in the sidebar.", + }, + commentAnnotationMode: { + type: "string", + title: "Comment File Links", + enum: ["annotation", "contextMenu", "both", "none"], + default: "both", + description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.", + }, + }, + }, + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "projectSidebarItem", + id: FILES_SIDEBAR_SLOT_ID, + displayName: "Files", + exportName: "FilesLink", + entityTypes: ["project"], + order: 10, + }, + { + type: "detailTab", + id: FILES_TAB_SLOT_ID, + displayName: "Files", + exportName: "FilesTab", + entityTypes: ["project"], + order: 10, + }, + { + type: "commentAnnotation", + id: COMMENT_FILE_LINKS_SLOT_ID, + displayName: "File Links", + exportName: "CommentFileLinks", + entityTypes: ["comment"], + }, + { + type: "commentContextMenuItem", + id: COMMENT_OPEN_FILES_SLOT_ID, + displayName: "Open in Files", + exportName: "CommentOpenFiles", + entityTypes: ["comment"], + }, + ], + }, +}; + +export default manifest; 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 new file mode 100644 index 00000000..0e12d903 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/ui/index.tsx @@ -0,0 +1,815 @@ +import type { + 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 { EditorView } from "@codemirror/view"; +import { basicSetup } from "codemirror"; +import { javascript } from "@codemirror/lang-javascript"; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { tags } from "@lezer/highlight"; + +const PLUGIN_KEY = "paperclip-file-browser-example"; +const FILES_TAB_SLOT_ID = "files-tab"; + +const editorBaseTheme = { + "&": { + height: "100%", + }, + ".cm-scroller": { + overflow: "auto", + fontFamily: + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace", + fontSize: "13px", + lineHeight: "1.6", + }, + ".cm-content": { + padding: "12px 14px 18px", + }, +}; + +const editorDarkTheme = EditorView.theme({ + ...editorBaseTheme, + "&": { + ...editorBaseTheme["&"], + backgroundColor: "oklch(0.23 0.02 255)", + color: "oklch(0.93 0.01 255)", + }, + ".cm-gutters": { + backgroundColor: "oklch(0.25 0.015 255)", + color: "oklch(0.74 0.015 255)", + borderRight: "1px solid oklch(0.34 0.01 255)", + }, + ".cm-activeLine, .cm-activeLineGutter": { + backgroundColor: "oklch(0.30 0.012 255 / 0.55)", + }, + ".cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "oklch(0.42 0.02 255 / 0.45)", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "oklch(0.47 0.025 255 / 0.5)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "oklch(0.93 0.01 255)", + }, + ".cm-matchingBracket": { + backgroundColor: "oklch(0.37 0.015 255 / 0.5)", + color: "oklch(0.95 0.01 255)", + outline: "none", + }, + ".cm-nonmatchingBracket": { + color: "oklch(0.70 0.08 24)", + }, +}, { dark: true }); + +const editorLightTheme = EditorView.theme({ + ...editorBaseTheme, + "&": { + ...editorBaseTheme["&"], + backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))", + color: "var(--foreground)", + }, + ".cm-content": { + ...editorBaseTheme[".cm-content"], + caretColor: "var(--foreground)", + }, + ".cm-gutters": { + backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))", + color: "var(--muted-foreground)", + borderRight: "1px solid var(--border)", + }, + ".cm-activeLine, .cm-activeLineGutter": { + backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)", + }, + ".cm-selectionBackground, .cm-content ::selection": { + backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)", + }, + "&.cm-focused .cm-selectionBackground": { + backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)", + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)", + }, + ".cm-matchingBracket": { + backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)", + color: "var(--foreground)", + outline: "none", + }, + ".cm-nonmatchingBracket": { + color: "var(--destructive)", + }, +}); + +const editorDarkHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "oklch(0.78 0.025 265)" }, + { tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" }, + { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" }, + { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" }, + { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" }, + { tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" }, + { tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" }, + { tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" }, +]); + +const editorLightHighlightStyle = HighlightStyle.define([ + { tag: tags.keyword, color: "oklch(0.45 0.07 270)" }, + { tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" }, + { tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" }, + { tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" }, + { tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" }, + { tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" }, + { tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" }, + { tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" }, + { tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" }, +]); + +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; +}; + +const PathLikePattern = /[\\/]/; +const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/; +const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function isLikelyPath(pathValue: string): boolean { + const trimmed = pathValue.trim(); + return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed); +} + +function workspaceLabel(workspace: Workspace): string { + const pathLabel = workspace.path.trim(); + const nameLabel = workspace.name.trim(); + const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel); + const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel); + const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : ""; + if (!baseLabel) { + return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)"; + } + + return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel; +} + +function useIsMobile(breakpointPx = 768): boolean { + const [isMobile, setIsMobile] = useState(() => + typeof window !== "undefined" ? window.innerWidth < breakpointPx : false, + ); + + useEffect(() => { + if (typeof window === "undefined") return; + const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`); + const update = () => setIsMobile(mediaQuery.matches); + update(); + mediaQuery.addEventListener("change", update); + return () => mediaQuery.removeEventListener("change", update); + }, [breakpointPx]); + + return isMobile; +} + +function useIsDarkMode(): boolean { + const [isDarkMode, setIsDarkMode] = useState(() => + typeof document !== "undefined" && document.documentElement.classList.contains("dark"), + ); + + useEffect(() => { + if (typeof document === "undefined") return; + const root = document.documentElement; + const update = () => setIsDarkMode(root.classList.contains("dark")); + update(); + + const observer = new MutationObserver(update); + observer.observe(root, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + + return isDarkMode; +} + +function useAvailableHeight( + ref: RefObject, + options?: { bottomPadding?: number; minHeight?: number }, +): number | null { + const bottomPadding = options?.bottomPadding ?? 24; + const minHeight = options?.minHeight ?? 384; + const [height, setHeight] = useState(null); + + useEffect(() => { + if (typeof window === "undefined") return; + + const update = () => { + const element = ref.current; + if (!element) return; + const rect = element.getBoundingClientRect(); + const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding)); + setHeight(nextHeight); + }; + + update(); + window.addEventListener("resize", update); + window.addEventListener("orientationchange", update); + + const observer = typeof ResizeObserver !== "undefined" + ? new ResizeObserver(() => update()) + : null; + if (observer && ref.current) observer.observe(ref.current); + + return () => { + window.removeEventListener("resize", update); + window.removeEventListener("orientationchange", update); + observer?.disconnect(); + }; + }, [bottomPadding, minHeight, ref]); + + 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. + */ +export function FilesLink({ context }: PluginProjectSidebarItemProps) { + const { data: config, loading: configLoading } = usePluginData("plugin-config", {}); + const showFilesInSidebar = config?.showFilesInSidebar ?? false; + + if (configLoading || !showFilesInSidebar) { + return null; + } + + const projectId = context.entityId; + const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null }) + .projectRef + ?? projectId; + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`; + const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`; + const isActive = typeof window !== "undefined" && (() => { + const pathname = window.location.pathname.replace(/\/+$/, ""); + const segments = pathname.split("/").filter(Boolean); + const projectsIndex = segments.indexOf("projects"); + const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null; + const activeTab = new URLSearchParams(window.location.search).get("tab"); + if (activeTab !== tabValue) return false; + if (!activeProjectRef) return false; + return activeProjectRef === projectId || activeProjectRef === projectRef; + })(); + + const handleClick = (event: MouseEvent) => { + if ( + event.defaultPrevented + || event.button !== 0 + || event.metaKey + || event.ctrlKey + || event.altKey + || event.shiftKey + ) { + return; + } + + event.preventDefault(); + window.history.pushState({}, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); + }; + + return ( + + Files + + ); +} + +/** + * Project detail tab: workspace selector, file tree, and CodeMirror editor. + */ +export function FilesTab({ context }: PluginDetailTabProps) { + const companyId = context.companyId; + const projectId = context.entityId; + const isMobile = useIsMobile(); + const isDarkMode = useIsDarkMode(); + const panesRef = useRef(null); + const availableHeight = useAvailableHeight(panesRef, { + bottomPadding: isMobile ? 16 : 24, + minHeight: isMobile ? 320 : 420, + }); + const { data: workspacesData } = usePluginData("workspaces", { + projectId, + companyId, + }); + const workspaces = workspacesData ?? []; + const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|"); + const [workspaceId, setWorkspaceId] = useState(null); + const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null; + const selectedWorkspace = useMemo( + () => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null, + [workspaces, resolvedWorkspaceId], + ); + + const fileListParams = useMemo( + () => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}), + [companyId, projectId, selectedWorkspace], + ); + const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>( + "fileList", + fileListParams, + ); + const entries = fileListData?.entries ?? []; + + // Track the `?file=` query parameter across navigations (popstate). + const [urlFilePath, setUrlFilePath] = useState(() => { + if (typeof window === "undefined") return null; + return new URLSearchParams(window.location.search).get("file") || null; + }); + const lastConsumedFileRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") return; + const onNav = () => { + const next = new URLSearchParams(window.location.search).get("file") || null; + setUrlFilePath(next); + }; + window.addEventListener("popstate", onNav); + return () => window.removeEventListener("popstate", onNav); + }, []); + + const [selectedPath, setSelectedPath] = useState(null); + useEffect(() => { + setSelectedPath(null); + setMobileView("browser"); + lastConsumedFileRef.current = null; + }, [selectedWorkspace?.id]); + + // When a file path appears (or changes) in the URL and workspace is ready, select it. + useEffect(() => { + if (!urlFilePath || !selectedWorkspace) return; + if (lastConsumedFileRef.current === urlFilePath) return; + lastConsumedFileRef.current = urlFilePath; + setSelectedPath(urlFilePath); + setMobileView("editor"); + }, [urlFilePath, selectedWorkspace]); + + const fileContentParams = useMemo( + () => + selectedPath && selectedWorkspace + ? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath } + : null, + [companyId, projectId, selectedWorkspace, selectedPath], + ); + const fileContentResult = usePluginData<{ content: string | null; error?: string }>( + "fileContent", + fileContentParams ?? {}, + ); + const { data: fileContentData, refresh: refreshFileContent } = fileContentResult; + const writeFile = usePluginAction("writeFile"); + const editorRef = useRef(null); + const viewRef = useRef(null); + const loadedContentRef = useRef(""); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [saveMessage, setSaveMessage] = useState(null); + const [saveError, setSaveError] = useState(null); + const [mobileView, setMobileView] = useState<"browser" | "editor">("browser"); + + useEffect(() => { + if (!editorRef.current) return; + const content = fileContentData?.content ?? ""; + loadedContentRef.current = content; + setIsDirty(false); + setSaveMessage(null); + setSaveError(null); + if (viewRef.current) { + viewRef.current.destroy(); + viewRef.current = null; + } + const view = new EditorView({ + doc: content, + extensions: [ + basicSetup, + javascript(), + isDarkMode ? editorDarkTheme : editorLightTheme, + syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle), + EditorView.updateListener.of((update) => { + if (!update.docChanged) return; + const nextValue = update.state.doc.toString(); + setIsDirty(nextValue !== loadedContentRef.current); + setSaveMessage(null); + setSaveError(null); + }), + ], + parent: editorRef.current, + }); + viewRef.current = view; + return () => { + view.destroy(); + viewRef.current = null; + }; + }, [fileContentData?.content, selectedPath, isDarkMode]); + + useEffect(() => { + const handleKeydown = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") { + return; + } + if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) { + return; + } + event.preventDefault(); + void handleSave(); + }; + + window.addEventListener("keydown", handleKeydown); + return () => window.removeEventListener("keydown", handleKeydown); + }, [selectedWorkspace, selectedPath, isDirty, isSaving]); + + async function handleSave() { + if (!selectedWorkspace || !selectedPath || !viewRef.current) { + return; + } + const content = viewRef.current.state.doc.toString(); + setIsSaving(true); + setSaveError(null); + setSaveMessage(null); + try { + await writeFile({ + projectId, + companyId, + workspaceId: selectedWorkspace.id, + filePath: selectedPath, + content, + }); + loadedContentRef.current = content; + setIsDirty(false); + setSaveMessage("Saved"); + refreshFileContent(); + } catch (error) { + setSaveError(error instanceof Error ? error.message : String(error)); + } finally { + setIsSaving(false); + } + } + + return ( +
    +
    + + +
    + +
    +
    +
    + File Tree +
    +
    + {selectedWorkspace ? ( + fileListLoading ? ( +

    Loading files...

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

    No files found in this workspace.

    + ) + ) : ( +

    Select a workspace to browse files.

    + )} +
    +
    +
    +
    +
    + +
    Editor
    +
    {selectedPath ?? "No file selected"}
    +
    +
    + +
    +
    + {isDirty || saveMessage || saveError ? ( +
    + {saveError ? ( + {saveError} + ) : saveMessage ? ( + {saveMessage} + ) : ( + Unsaved changes + )} +
    + ) : null} + {selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? ( +
    {fileContentData.error}
    + ) : null} +
    +
    +
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Comment Annotation: renders detected file links below each comment +// --------------------------------------------------------------------------- + +type PluginConfig = { + showFilesInSidebar?: boolean; + commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none"; +}; + +/** + * Per-comment annotation showing file-path-like links extracted from the + * comment body. Each link navigates to the project Files tab with the + * matching path pre-selected. + * + * Respects the `commentAnnotationMode` instance config — hidden when mode + * is `"contextMenu"` or `"none"`. + */ +function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string { + if (!projectId) return "#"; + const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`; + return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`; +} + +function navigateToFileBrowser(href: string, event: MouseEvent) { + if ( + event.defaultPrevented + || event.button !== 0 + || event.metaKey + || event.ctrlKey + || event.altKey + || event.shiftKey + ) { + return; + } + event.preventDefault(); + window.history.pushState({}, "", href); + window.dispatchEvent(new PopStateEvent("popstate")); +} + +export function CommentFileLinks({ context }: PluginCommentAnnotationProps) { + const { data: config } = usePluginData("plugin-config", {}); + const mode = config?.commentAnnotationMode ?? "both"; + + const { data } = usePluginData<{ links: string[] }>("comment-file-links", { + commentId: context.entityId, + issueId: context.parentEntityId, + companyId: context.companyId, + }); + + if (mode === "contextMenu" || mode === "none") return null; + if (!data?.links?.length) return null; + + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const projectId = context.projectId; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Comment Context Menu Item: "Open in Files" action per comment +// --------------------------------------------------------------------------- + +/** + * Per-comment context menu item that appears in the comment "more" (⋮) menu. + * Extracts file paths from the comment body and, if any are found, renders + * a button to open the first file in the project Files tab. + * + * Respects the `commentAnnotationMode` instance config — hidden when mode + * is `"annotation"` or `"none"`. + */ +export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) { + const { data: config } = usePluginData("plugin-config", {}); + const mode = config?.commentAnnotationMode ?? "both"; + + const { data } = usePluginData<{ links: string[] }>("comment-file-links", { + commentId: context.entityId, + issueId: context.parentEntityId, + companyId: context.companyId, + }); + + if (mode === "annotation" || mode === "none") return null; + if (!data?.links?.length) return null; + + const prefix = context.companyPrefix ? `/${context.companyPrefix}` : ""; + const projectId = context.projectId; + + return ( +
    +
    + Files +
    + {data.links.map((link) => { + const href = buildFileBrowserHref(prefix, projectId, link); + const fileName = link.split("/").pop() ?? link; + return ( + navigateToFileBrowser(href, e)} + className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors" + title={`Open ${link} in file browser`} + > + {fileName} + + ); + })} +
    + ); +} diff --git a/packages/plugins/examples/plugin-file-browser-example/src/worker.ts b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts new file mode 100644 index 00000000..a1689834 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/src/worker.ts @@ -0,0 +1,226 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const PLUGIN_NAME = "file-browser-example"; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const PATH_LIKE_PATTERN = /[\\/]/; +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; + +function looksLikePath(value: string): boolean { + const normalized = value.trim(); + return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized)) + && !UUID_PATTERN.test(normalized); +} + +function sanitizeWorkspacePath(pathValue: string): string { + return looksLikePath(pathValue) ? pathValue.trim() : ""; +} + +function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null { + const root = path.resolve(workspacePath); + const resolved = requestedPath ? path.resolve(root, requestedPath) : root; + const relative = path.relative(root, resolved); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return null; + } + return resolved; +} + +/** + * Regex that matches file-path-like tokens in comment text. + * Captures tokens that either start with `.` `/` `~` or contain a `/` + * (directory separator), plus bare words that could be filenames with + * extensions (e.g. `README.md`). The file-extension check in + * `extractFilePaths` filters out non-file matches. + */ +const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g; + +/** Common file extensions to recognise path-like tokens as actual file references. */ +const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/; + +/** + * Tokens that look like paths but are almost certainly URL route segments + * (e.g. `/projects/abc`, `/settings`, `/dashboard`). + */ +const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i; + +function extractFilePaths(body: string): string[] { + const paths = new Set(); + for (const match of body.matchAll(FILE_PATH_REGEX)) { + const raw = match[1]; + // Strip trailing punctuation that isn't part of a path + const cleaned = raw.replace(/[.:,;!?)]+$/, ""); + if (cleaned.length <= 1) continue; + // Must have a file extension (e.g. .ts, .json, .md) + if (!FILE_EXTENSION_REGEX.test(cleaned)) continue; + // Skip things that look like URL routes + if (URL_ROUTE_PATTERN.test(cleaned)) continue; + paths.add(cleaned); + } + return [...paths]; +} + +const plugin = definePlugin({ + async setup(ctx) { + ctx.logger.info(`${PLUGIN_NAME} plugin setup`); + + // Expose the current plugin config so UI components can read operator + // settings from the canonical instance config store. + ctx.data.register("plugin-config", async () => { + const config = await ctx.config.get(); + return { + showFilesInSidebar: config?.showFilesInSidebar === true, + commentAnnotationMode: config?.commentAnnotationMode ?? "both", + }; + }); + + // Fetch a comment by ID and extract file-path-like tokens from its body. + ctx.data.register("comment-file-links", async (params: Record) => { + const commentId = typeof params.commentId === "string" ? params.commentId : ""; + const issueId = typeof params.issueId === "string" ? params.issueId : ""; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + if (!commentId || !issueId || !companyId) return { links: [] }; + try { + const comments = await ctx.issues.listComments(issueId, companyId); + const comment = comments.find((c) => c.id === commentId); + if (!comment?.body) return { links: [] }; + return { links: extractFilePaths(comment.body) }; + } catch (err) { + ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) }); + return { links: [] }; + } + }); + + ctx.data.register("workspaces", async (params: Record) => { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + if (!projectId || !companyId) return []; + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + return workspaces.map((w) => ({ + id: w.id, + projectId: w.projectId, + name: w.name, + path: sanitizeWorkspacePath(w.path), + isPrimary: w.isPrimary, + })); + }); + + 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 }; + }, + ); + + ctx.data.register( + "fileContent", + async (params: Record) => { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + const workspaceId = params.workspaceId as string; + const filePath = params.filePath as string; + if (!projectId || !companyId || !workspaceId || !filePath) { + return { content: null, error: "Missing file context" }; + } + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((w) => w.id === workspaceId); + if (!workspace) return { content: null, error: "Workspace not found" }; + const workspacePath = sanitizeWorkspacePath(workspace.path); + if (!workspacePath) return { content: null, error: "Workspace has no path" }; + const fullPath = resolveWorkspace(workspacePath, filePath); + if (!fullPath) { + return { content: null, error: "Path outside workspace" }; + } + try { + const content = fs.readFileSync(fullPath, "utf-8"); + return { content }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { content: null, error: message }; + } + }, + ); + + ctx.actions.register( + "writeFile", + async (params: Record) => { + const projectId = params.projectId as string; + const companyId = typeof params.companyId === "string" ? params.companyId : ""; + const workspaceId = params.workspaceId as string; + const filePath = typeof params.filePath === "string" ? params.filePath.trim() : ""; + if (!filePath) { + throw new Error("filePath must be a non-empty string"); + } + const content = typeof params.content === "string" ? params.content : null; + if (!projectId || !companyId || !workspaceId) { + throw new Error("Missing workspace context"); + } + const workspaces = await ctx.projects.listWorkspaces(projectId, companyId); + const workspace = workspaces.find((w) => w.id === workspaceId); + if (!workspace) { + throw new Error("Workspace not found"); + } + const workspacePath = sanitizeWorkspacePath(workspace.path); + if (!workspacePath) { + throw new Error("Workspace has no path"); + } + if (content === null) { + throw new Error("Missing file content"); + } + const fullPath = resolveWorkspace(workspacePath, filePath); + if (!fullPath) { + throw new Error("Path outside workspace"); + } + const stat = fs.statSync(fullPath); + if (!stat.isFile()) { + throw new Error("Selected path is not a file"); + } + fs.writeFileSync(fullPath, content, "utf-8"); + return { + ok: true, + path: filePath, + bytes: Buffer.byteLength(content, "utf-8"), + }; + }, + ); + }, + + async onHealth() { + return { status: "ok", message: `${PLUGIN_NAME} ready` }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-file-browser-example/tsconfig.json b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json new file mode 100644 index 00000000..3482c173 --- /dev/null +++ b/packages/plugins/examples/plugin-file-browser-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-hello-world-example/README.md b/packages/plugins/examples/plugin-hello-world-example/README.md new file mode 100644 index 00000000..889c9d25 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/README.md @@ -0,0 +1,38 @@ +# @paperclipai/plugin-hello-world-example + +First-party reference plugin showing the smallest possible UI extension. + +## What It Demonstrates + +- a manifest with a `dashboardWidget` UI slot +- `entrypoints.ui` wiring for plugin UI bundles +- a minimal React widget rendered in the Paperclip dashboard +- reading host context (`companyId`) from `PluginWidgetProps` +- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability + +## API Surface + +- This example does not add custom HTTP endpoints. +- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`). + +## Notes + +This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors. +It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds. + +## Local Install (Dev) + +From the repo root, build the plugin and install it by local path: + +```bash +pnpm --filter @paperclipai/plugin-hello-world-example build +pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example +``` + +**Local development notes:** + +- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists. +- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path. +- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin: + `pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then + `pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`. diff --git a/packages/plugins/examples/plugin-hello-world-example/package.json b/packages/plugins/examples/plugin-hello-world-example/package.json new file mode 100644 index 00000000..5d055caa --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/package.json @@ -0,0 +1,35 @@ +{ + "name": "@paperclipai/plugin-hello-world-example", + "version": "0.1.0", + "description": "First-party reference plugin that adds a Hello World dashboard widget", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "tsc", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-hello-world-example/src/index.ts b/packages/plugins/examples/plugin-hello-world-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts new file mode 100644 index 00000000..2fcd8077 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/manifest.ts @@ -0,0 +1,39 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; + +/** + * Stable plugin ID used by host registration and namespacing. + */ +const PLUGIN_ID = "paperclip.hello-world-example"; +const PLUGIN_VERSION = "0.1.0"; +const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget"; +const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget"; + +/** + * Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot. + */ +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: PLUGIN_VERSION, + displayName: "Hello World Widget (Example)", + description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.", + author: "Paperclip", + categories: ["ui"], + capabilities: ["ui.dashboardWidget.register"], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + ui: { + slots: [ + { + type: "dashboardWidget", + id: DASHBOARD_WIDGET_SLOT_ID, + displayName: "Hello World", + exportName: DASHBOARD_WIDGET_EXPORT_NAME, + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx new file mode 100644 index 00000000..10e12fb0 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/ui/index.tsx @@ -0,0 +1,17 @@ +import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui"; + +const WIDGET_LABEL = "Hello world plugin widget"; + +/** + * Example dashboard widget showing the smallest possible UI contribution. + */ +export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) { + return ( +
    + Hello world +
    This widget was added by @paperclipai/plugin-hello-world-example.
    + {/* Include host context so authors can see where scoped IDs come from. */} +
    Company context: {context.companyId}
    +
    + ); +} diff --git a/packages/plugins/examples/plugin-hello-world-example/src/worker.ts b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts new file mode 100644 index 00000000..07c7fbea --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/src/worker.ts @@ -0,0 +1,27 @@ +import { definePlugin, runWorker } from "@paperclipai/plugin-sdk"; + +const PLUGIN_NAME = "hello-world-example"; +const HEALTH_MESSAGE = "Hello World example plugin ready"; + +/** + * Worker lifecycle hooks for the Hello World reference plugin. + * This stays intentionally small so new authors can copy the shape quickly. + */ +const plugin = definePlugin({ + /** + * Called when the host starts the plugin worker. + */ + async setup(ctx) { + ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`); + }, + + /** + * Called by the host health probe endpoint. + */ + async onHealth() { + return { status: "ok", message: HEALTH_MESSAGE }; + }, +}); + +export default plugin; +runWorker(plugin, import.meta.url); diff --git a/packages/plugins/examples/plugin-hello-world-example/tsconfig.json b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json new file mode 100644 index 00000000..3482c173 --- /dev/null +++ b/packages/plugins/examples/plugin-hello-world-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2023", "DOM"], + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/README.md b/packages/plugins/examples/plugin-kitchen-sink-example/README.md new file mode 100644 index 00000000..bfa4ec52 --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/README.md @@ -0,0 +1,33 @@ +# @paperclipai/plugin-kitchen-sink-example + +Kitchen Sink is the first-party reference plugin that demonstrates nearly the full currently implemented Paperclip plugin surface in one package. + +It is intentionally broad: + +- full plugin page +- dashboard widget +- project and issue surfaces +- comment surfaces +- sidebar surfaces +- settings page +- worker bridge data/actions +- events, jobs, webhooks, tools, streams +- state, entities, assets, metrics, activity +- local workspace and process demos + +This plugin is for local development, contributor onboarding, and runtime regression testing. It is not meant as a production plugin template to ship unchanged. + +## Install + +```sh +pnpm --filter @paperclipai/plugin-kitchen-sink-example build +pnpm paperclipai plugin install ./packages/plugins/examples/plugin-kitchen-sink-example +``` + +Or install it from the Paperclip plugin manager as a bundled example once this repo is built. + +## Notes + +- Local workspace and process demos are trusted-only and default to safe, curated commands. +- The plugin settings page lets you toggle optional demo surfaces and local runtime behavior. +- Some SDK-defined host surfaces still depend on the Paperclip host wiring them visibly; this package aims to exercise the currently mounted ones and make the rest obvious. diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/package.json b/packages/plugins/examples/plugin-kitchen-sink-example/package.json new file mode 100644 index 00000000..467ff039 --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/package.json @@ -0,0 +1,37 @@ +{ + "name": "@paperclipai/plugin-kitchen-sink-example", + "version": "0.1.0", + "description": "Reference plugin that demonstrates the full Paperclip plugin surface area in one package", + "type": "module", + "private": true, + "exports": { + ".": "./src/index.ts" + }, + "paperclipPlugin": { + "manifest": "./dist/manifest.js", + "worker": "./dist/worker.js", + "ui": "./dist/ui/" + }, + "scripts": { + "prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs", + "build": "tsc && node ./scripts/build-ui.mjs", + "clean": "rm -rf dist", + "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + }, + "dependencies": { + "@paperclipai/plugin-sdk": "workspace:*", + "@paperclipai/shared": "workspace:*" + }, + "devDependencies": { + "esbuild": "^0.27.3", + "@types/node": "^24.6.0", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.7.3" + }, + "peerDependencies": { + "react": ">=18" + } +} diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/scripts/build-ui.mjs b/packages/plugins/examples/plugin-kitchen-sink-example/scripts/build-ui.mjs new file mode 100644 index 00000000..5cd75637 --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/scripts/build-ui.mjs @@ -0,0 +1,24 @@ +import esbuild from "esbuild"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const packageRoot = path.resolve(__dirname, ".."); + +await esbuild.build({ + entryPoints: [path.join(packageRoot, "src/ui/index.tsx")], + outfile: path.join(packageRoot, "dist/ui/index.js"), + bundle: true, + format: "esm", + platform: "browser", + target: ["es2022"], + sourcemap: true, + external: [ + "react", + "react-dom", + "react/jsx-runtime", + "@paperclipai/plugin-sdk/ui", + ], + logLevel: "info", +}); diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts new file mode 100644 index 00000000..9c18f610 --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/constants.ts @@ -0,0 +1,113 @@ +import type { PluginLauncherRegistration } from "@paperclipai/plugin-sdk"; + +export const PLUGIN_ID = "paperclip-kitchen-sink-example"; +export const PLUGIN_VERSION = "0.1.0"; +export const PAGE_ROUTE = "kitchensink"; + +export const SLOT_IDS = { + page: "kitchen-sink-page", + settingsPage: "kitchen-sink-settings-page", + dashboardWidget: "kitchen-sink-dashboard-widget", + sidebar: "kitchen-sink-sidebar-link", + sidebarPanel: "kitchen-sink-sidebar-panel", + projectSidebarItem: "kitchen-sink-project-link", + projectTab: "kitchen-sink-project-tab", + issueTab: "kitchen-sink-issue-tab", + taskDetailView: "kitchen-sink-task-detail", + toolbarButton: "kitchen-sink-toolbar-action", + contextMenuItem: "kitchen-sink-context-action", + commentAnnotation: "kitchen-sink-comment-annotation", + commentContextMenuItem: "kitchen-sink-comment-action", +} as const; + +export const EXPORT_NAMES = { + page: "KitchenSinkPage", + settingsPage: "KitchenSinkSettingsPage", + dashboardWidget: "KitchenSinkDashboardWidget", + sidebar: "KitchenSinkSidebarLink", + sidebarPanel: "KitchenSinkSidebarPanel", + projectSidebarItem: "KitchenSinkProjectSidebarItem", + projectTab: "KitchenSinkProjectTab", + issueTab: "KitchenSinkIssueTab", + taskDetailView: "KitchenSinkTaskDetailView", + toolbarButton: "KitchenSinkToolbarButton", + contextMenuItem: "KitchenSinkContextMenuItem", + commentAnnotation: "KitchenSinkCommentAnnotation", + commentContextMenuItem: "KitchenSinkCommentContextMenuItem", + launcherModal: "KitchenSinkLauncherModal", +} as const; + +export const JOB_KEYS = { + heartbeat: "demo-heartbeat", +} as const; + +export const WEBHOOK_KEYS = { + demo: "demo-ingest", +} as const; + +export const TOOL_NAMES = { + echo: "echo", + companySummary: "company-summary", + createIssue: "create-issue", +} as const; + +export const STREAM_CHANNELS = { + progress: "progress", + agentChat: "agent-chat", +} as const; + +export const SAFE_COMMANDS = [ + { + key: "pwd", + label: "Print workspace path", + command: "pwd", + args: [] as string[], + description: "Prints the current workspace directory.", + }, + { + key: "ls", + label: "List workspace files", + command: "ls", + args: ["-la"] as string[], + description: "Lists files in the selected workspace.", + }, + { + key: "git-status", + label: "Git status", + command: "git", + args: ["status", "--short", "--branch"] as string[], + description: "Shows git status for the selected workspace.", + }, +] as const; + +export type SafeCommandKey = (typeof SAFE_COMMANDS)[number]["key"]; + +export const DEFAULT_CONFIG = { + showSidebarEntry: true, + showSidebarPanel: true, + showProjectSidebarItem: true, + showCommentAnnotation: true, + showCommentContextMenuItem: true, + enableWorkspaceDemos: true, + enableProcessDemos: false, + secretRefExample: "", + httpDemoUrl: "https://httpbin.org/anything", + allowedCommands: SAFE_COMMANDS.map((command) => command.key), + workspaceScratchFile: ".paperclip-kitchen-sink-demo.txt", +} as const; + +export const RUNTIME_LAUNCHER: PluginLauncherRegistration = { + id: "kitchen-sink-runtime-launcher", + displayName: "Kitchen Sink Modal", + description: "Demonstrates runtime launcher registration from the worker.", + placementZone: "toolbarButton", + entityTypes: ["project", "issue"], + action: { + type: "openModal", + target: EXPORT_NAMES.launcherModal, + }, + render: { + environment: "hostOverlay", + bounds: "wide", + }, +}; diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/index.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/index.ts new file mode 100644 index 00000000..f301da5d --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/index.ts @@ -0,0 +1,2 @@ +export { default as manifest } from "./manifest.js"; +export { default as worker } from "./worker.js"; diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts new file mode 100644 index 00000000..bb3215c2 --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/manifest.ts @@ -0,0 +1,290 @@ +import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk"; +import { + DEFAULT_CONFIG, + EXPORT_NAMES, + JOB_KEYS, + PAGE_ROUTE, + PLUGIN_ID, + PLUGIN_VERSION, + SLOT_IDS, + TOOL_NAMES, + WEBHOOK_KEYS, +} from "./constants.js"; + +const manifest: PaperclipPluginManifestV1 = { + id: PLUGIN_ID, + apiVersion: 1, + version: PLUGIN_VERSION, + displayName: "Kitchen Sink (Example)", + description: "Reference plugin that demonstrates the current Paperclip plugin API surface, UI surfaces, bridge actions, events, jobs, webhooks, tools, local workspace access, and runtime diagnostics in one place.", + author: "Paperclip", + categories: ["ui", "automation", "workspace", "connector"], + capabilities: [ + "companies.read", + "projects.read", + "project.workspaces.read", + "issues.read", + "issues.create", + "issues.update", + "issue.comments.read", + "issue.comments.create", + "agents.read", + "agents.pause", + "agents.resume", + "agents.invoke", + "agent.sessions.create", + "agent.sessions.list", + "agent.sessions.send", + "agent.sessions.close", + "goals.read", + "goals.create", + "goals.update", + "activity.log.write", + "metrics.write", + "plugin.state.read", + "plugin.state.write", + "events.subscribe", + "events.emit", + "jobs.schedule", + "webhooks.receive", + "http.outbound", + "secrets.read-ref", + "agent.tools.register", + "instance.settings.register", + "ui.sidebar.register", + "ui.page.register", + "ui.detailTab.register", + "ui.dashboardWidget.register", + "ui.commentAnnotation.register", + "ui.action.register", + ], + entrypoints: { + worker: "./dist/worker.js", + ui: "./dist/ui", + }, + instanceConfigSchema: { + type: "object", + properties: { + showSidebarEntry: { + type: "boolean", + title: "Show Sidebar Entry", + default: DEFAULT_CONFIG.showSidebarEntry, + }, + showSidebarPanel: { + type: "boolean", + title: "Show Sidebar Panel", + default: DEFAULT_CONFIG.showSidebarPanel, + }, + showProjectSidebarItem: { + type: "boolean", + title: "Show Project Sidebar Item", + default: DEFAULT_CONFIG.showProjectSidebarItem, + }, + showCommentAnnotation: { + type: "boolean", + title: "Show Comment Annotation", + default: DEFAULT_CONFIG.showCommentAnnotation, + }, + showCommentContextMenuItem: { + type: "boolean", + title: "Show Comment Action", + default: DEFAULT_CONFIG.showCommentContextMenuItem, + }, + enableWorkspaceDemos: { + type: "boolean", + title: "Enable Workspace Demos", + default: DEFAULT_CONFIG.enableWorkspaceDemos, + }, + enableProcessDemos: { + type: "boolean", + title: "Enable Process Demos", + default: DEFAULT_CONFIG.enableProcessDemos, + description: "Allows curated local child-process demos in project workspaces.", + }, + secretRefExample: { + type: "string", + title: "Secret Reference Example", + default: DEFAULT_CONFIG.secretRefExample, + }, + httpDemoUrl: { + type: "string", + title: "HTTP Demo URL", + default: DEFAULT_CONFIG.httpDemoUrl, + }, + allowedCommands: { + type: "array", + title: "Allowed Process Commands", + items: { + type: "string", + enum: DEFAULT_CONFIG.allowedCommands, + }, + default: DEFAULT_CONFIG.allowedCommands, + }, + workspaceScratchFile: { + type: "string", + title: "Workspace Scratch File", + default: DEFAULT_CONFIG.workspaceScratchFile, + }, + }, + }, + jobs: [ + { + jobKey: JOB_KEYS.heartbeat, + displayName: "Demo Heartbeat", + description: "Periodic demo job that records plugin runtime activity.", + schedule: "*/15 * * * *", + }, + ], + webhooks: [ + { + endpointKey: WEBHOOK_KEYS.demo, + displayName: "Demo Ingest", + description: "Accepts arbitrary webhook payloads and records the latest delivery in plugin state.", + }, + ], + tools: [ + { + name: TOOL_NAMES.echo, + displayName: "Kitchen Sink Echo", + description: "Returns the provided message and the current run context.", + parametersSchema: { + type: "object", + properties: { + message: { type: "string" }, + }, + required: ["message"], + }, + }, + { + name: TOOL_NAMES.companySummary, + displayName: "Kitchen Sink Company Summary", + description: "Summarizes the current company using the Paperclip domain APIs.", + parametersSchema: { + type: "object", + properties: {}, + }, + }, + { + name: TOOL_NAMES.createIssue, + displayName: "Kitchen Sink Create Issue", + description: "Creates an issue in the current project from an agent tool call.", + parametersSchema: { + type: "object", + properties: { + title: { type: "string" }, + description: { type: "string" }, + }, + required: ["title"], + }, + }, + ], + ui: { + slots: [ + { + type: "page", + id: SLOT_IDS.page, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.page, + routePath: PAGE_ROUTE, + }, + { + type: "settingsPage", + id: SLOT_IDS.settingsPage, + displayName: "Kitchen Sink Settings", + exportName: EXPORT_NAMES.settingsPage, + }, + { + type: "dashboardWidget", + id: SLOT_IDS.dashboardWidget, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.dashboardWidget, + }, + { + type: "sidebar", + id: SLOT_IDS.sidebar, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.sidebar, + }, + { + type: "sidebarPanel", + id: SLOT_IDS.sidebarPanel, + displayName: "Kitchen Sink Panel", + exportName: EXPORT_NAMES.sidebarPanel, + }, + { + type: "projectSidebarItem", + id: SLOT_IDS.projectSidebarItem, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.projectSidebarItem, + entityTypes: ["project"], + }, + { + type: "detailTab", + id: SLOT_IDS.projectTab, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.projectTab, + entityTypes: ["project"], + }, + { + type: "detailTab", + id: SLOT_IDS.issueTab, + displayName: "Kitchen Sink", + exportName: EXPORT_NAMES.issueTab, + entityTypes: ["issue"], + }, + { + type: "taskDetailView", + id: SLOT_IDS.taskDetailView, + displayName: "Kitchen Sink Task View", + exportName: EXPORT_NAMES.taskDetailView, + entityTypes: ["issue"], + }, + { + type: "toolbarButton", + id: SLOT_IDS.toolbarButton, + displayName: "Kitchen Sink Action", + exportName: EXPORT_NAMES.toolbarButton, + entityTypes: ["project", "issue"], + }, + { + type: "contextMenuItem", + id: SLOT_IDS.contextMenuItem, + displayName: "Kitchen Sink Context", + exportName: EXPORT_NAMES.contextMenuItem, + entityTypes: ["project", "issue"], + }, + { + type: "commentAnnotation", + id: SLOT_IDS.commentAnnotation, + displayName: "Kitchen Sink Comment Annotation", + exportName: EXPORT_NAMES.commentAnnotation, + entityTypes: ["comment"], + }, + { + type: "commentContextMenuItem", + id: SLOT_IDS.commentContextMenuItem, + displayName: "Kitchen Sink Comment Action", + exportName: EXPORT_NAMES.commentContextMenuItem, + entityTypes: ["comment"], + }, + ], + launchers: [ + { + id: "kitchen-sink-launcher", + displayName: "Kitchen Sink Modal", + placementZone: "toolbarButton", + entityTypes: ["project", "issue"], + action: { + type: "openModal", + target: EXPORT_NAMES.launcherModal, + }, + render: { + environment: "hostOverlay", + bounds: "wide", + }, + }, + ], + }, +}; + +export default manifest; diff --git a/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/AsciiArtAnimation.tsx b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/AsciiArtAnimation.tsx new file mode 100644 index 00000000..01cad1be --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/AsciiArtAnimation.tsx @@ -0,0 +1,363 @@ +import { useEffect, useRef } from "react"; + +const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const; +const TARGET_FPS = 24; +const FRAME_INTERVAL_MS = 1000 / TARGET_FPS; + +const PAPERCLIP_SPRITES = [ + [ + " ╭────╮ ", + " ╭╯╭──╮│ ", + " │ │ ││ ", + " │ │ ││ ", + " │ │ ││ ", + " │ │ ││ ", + " │ ╰──╯│ ", + " ╰─────╯ ", + ], + [ + " ╭─────╮ ", + " │╭──╮╰╮ ", + " ││ │ │ ", + " ││ │ │ ", + " ││ │ │ ", + " ││ │ │ ", + " │╰──╯ │ ", + " ╰────╯ ", + ], +] as const; + +type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number]; + +interface Clip { + x: number; + y: number; + vx: number; + vy: number; + life: number; + maxLife: number; + drift: number; + sprite: PaperclipSprite; + width: number; + height: number; +} + +function measureChar(container: HTMLElement): { w: number; h: number } { + const span = document.createElement("span"); + span.textContent = "M"; + span.style.cssText = + "position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;"; + container.appendChild(span); + const rect = span.getBoundingClientRect(); + container.removeChild(span); + return { w: rect.width, h: rect.height }; +} + +function spriteSize(sprite: PaperclipSprite): { width: number; height: number } { + let width = 0; + for (const row of sprite) width = Math.max(width, row.length); + return { width, height: sprite.length }; +} + +export function AsciiArtAnimation() { + const preRef = useRef(null); + const frameRef = useRef(null); + + useEffect(() => { + if (!preRef.current) return; + const preEl: HTMLPreElement = preRef.current; + const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)"); + let isVisible = document.visibilityState !== "hidden"; + let loopActive = false; + let lastRenderAt = 0; + let tick = 0; + let cols = 0; + let rows = 0; + let charW = 7; + let charH = 11; + let trail = new Float32Array(0); + let colWave = new Float32Array(0); + let rowWave = new Float32Array(0); + let clipMask = new Uint16Array(0); + let clips: Clip[] = []; + let lastOutput = ""; + + function toGlyph(value: number): string { + const clamped = Math.max(0, Math.min(0.999, value)); + const idx = Math.floor(clamped * CHARS.length); + return CHARS[idx] ?? " "; + } + + function rebuildGrid() { + const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW))); + const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH))); + if (nextCols === cols && nextRows === rows) return; + + cols = nextCols; + rows = nextRows; + const cellCount = cols * rows; + trail = new Float32Array(cellCount); + colWave = new Float32Array(cols); + rowWave = new Float32Array(rows); + clipMask = new Uint16Array(cellCount); + clips = clips.filter((clip) => { + return ( + clip.x > -clip.width - 2 && + clip.x < cols + 2 && + clip.y > -clip.height - 2 && + clip.y < rows + 2 + ); + }); + lastOutput = ""; + } + + function drawStaticFrame() { + if (cols <= 0 || rows <= 0) { + preEl.textContent = ""; + return; + } + + const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " ")); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22; + grid[r]![c] = toGlyph(ambient); + } + } + + const gapX = 18; + const gapY = 13; + for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) { + const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10; + for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) { + const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!; + for (let sr = 0; sr < sprite.length; sr++) { + const line = sprite[sr]!; + for (let sc = 0; sc < line.length; sc++) { + const ch = line[sc] ?? " "; + if (ch === " ") continue; + const row = baseRow + sr; + const col = baseCol + sc; + if (row < 0 || row >= rows || col < 0 || col >= cols) continue; + grid[row]![col] = ch; + } + } + } + } + + const output = grid.map((line) => line.join("")).join("\n"); + preEl.textContent = output; + lastOutput = output; + } + + function spawnClip() { + const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!; + const size = spriteSize(sprite); + const edge = Math.random(); + let x = 0; + let y = 0; + let vx = 0; + let vy = 0; + + if (edge < 0.68) { + x = Math.random() < 0.5 ? -size.width - 1 : cols + 1; + y = Math.random() * Math.max(1, rows - size.height); + vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05); + vy = (Math.random() - 0.5) * 0.014; + } else { + x = Math.random() * Math.max(1, cols - size.width); + y = Math.random() < 0.5 ? -size.height - 1 : rows + 1; + vx = (Math.random() - 0.5) * 0.014; + vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034); + } + + clips.push({ + x, + y, + vx, + vy, + life: 0, + maxLife: 260 + Math.random() * 220, + drift: (Math.random() - 0.5) * 1.2, + sprite, + width: size.width, + height: size.height, + }); + } + + function stampClip(clip: Clip, alpha: number) { + const baseCol = Math.round(clip.x); + const baseRow = Math.round(clip.y); + for (let sr = 0; sr < clip.sprite.length; sr++) { + const line = clip.sprite[sr]!; + const row = baseRow + sr; + if (row < 0 || row >= rows) continue; + for (let sc = 0; sc < line.length; sc++) { + const ch = line[sc] ?? " "; + if (ch === " ") continue; + const col = baseCol + sc; + if (col < 0 || col >= cols) continue; + const idx = row * cols + col; + const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92; + trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke); + clipMask[idx] = ch.charCodeAt(0); + } + } + } + + function step(time: number) { + if (!loopActive) return; + frameRef.current = requestAnimationFrame(step); + if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return; + + const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667); + lastRenderAt = time; + tick += delta; + + const cellCount = cols * rows; + const targetCount = Math.max(3, Math.floor(cellCount / 2200)); + while (clips.length < targetCount) spawnClip(); + + for (let i = 0; i < trail.length; i++) trail[i] *= 0.92; + clipMask.fill(0); + + for (let i = clips.length - 1; i >= 0; i--) { + const clip = clips[i]!; + clip.life += delta; + + const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018; + const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014; + clip.vx = (clip.vx + wobbleX) * 0.998; + clip.vy = (clip.vy + wobbleY) * 0.998; + + clip.x += clip.vx * delta; + clip.y += clip.vy * delta; + + if ( + clip.life >= clip.maxLife || + clip.x < -clip.width - 2 || + clip.x > cols + 2 || + clip.y < -clip.height - 2 || + clip.y > rows + 2 + ) { + clips.splice(i, 1); + continue; + } + + const life = clip.life / clip.maxLife; + const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1; + stampClip(clip, alpha); + } + + for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06); + for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05); + + let output = ""; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const idx = r * cols + c; + const clipChar = clipMask[idx]; + if (clipChar > 0) { + output += String.fromCharCode(clipChar); + continue; + } + + const ambient = 0.2 + colWave[c]! * 0.08 + rowWave[r]! * 0.06 + Math.sin((c + r) * 0.1 + tick * 0.035) * 0.05; + output += toGlyph((trail[idx] ?? 0) + ambient); + } + if (r < rows - 1) output += "\n"; + } + + if (output !== lastOutput) { + preEl.textContent = output; + lastOutput = output; + } + } + + const resizeObserver = new ResizeObserver(() => { + const measured = measureChar(preEl); + charW = measured.w || 7; + charH = measured.h || 11; + rebuildGrid(); + if (motionMedia.matches || !isVisible) { + drawStaticFrame(); + } + }); + + function startLoop() { + if (loopActive) return; + loopActive = true; + lastRenderAt = 0; + frameRef.current = requestAnimationFrame(step); + } + + function stopLoop() { + loopActive = false; + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + } + + function syncMode() { + if (motionMedia.matches || !isVisible) { + stopLoop(); + drawStaticFrame(); + } else { + startLoop(); + } + } + + function handleVisibility() { + isVisible = document.visibilityState !== "hidden"; + syncMode(); + } + + const measured = measureChar(preEl); + charW = measured.w || 7; + charH = measured.h || 11; + rebuildGrid(); + resizeObserver.observe(preEl); + motionMedia.addEventListener("change", syncMode); + document.addEventListener("visibilitychange", handleVisibility); + syncMode(); + + return () => { + stopLoop(); + resizeObserver.disconnect(); + motionMedia.removeEventListener("change", syncMode); + document.removeEventListener("visibilitychange", handleVisibility); + }; + }, []); + + return ( +
    +
    + ); +} 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 new file mode 100644 index 00000000..826dd832 --- /dev/null +++ b/packages/plugins/examples/plugin-kitchen-sink-example/src/ui/index.tsx @@ -0,0 +1,2405 @@ +import { useEffect, useMemo, useState, type CSSProperties, type FormEvent, type ReactNode } from "react"; +import { + useHostContext, + usePluginAction, + usePluginData, + usePluginStream, + usePluginToast, + type PluginCommentAnnotationProps, + type PluginCommentContextMenuItemProps, + type PluginDetailTabProps, + type PluginPageProps, + type PluginProjectSidebarItemProps, + type PluginSettingsPageProps, + type PluginSidebarProps, + type PluginWidgetProps, +} from "@paperclipai/plugin-sdk/ui"; +import { + DEFAULT_CONFIG, + JOB_KEYS, + PAGE_ROUTE, + PLUGIN_ID, + SAFE_COMMANDS, + SLOT_IDS, + STREAM_CHANNELS, + TOOL_NAMES, + WEBHOOK_KEYS, +} from "../constants.js"; +import { AsciiArtAnimation } from "./AsciiArtAnimation.js"; + +type CompanyRecord = { id: string; name: string; issuePrefix?: string | null; status?: string | null }; +type ProjectRecord = { id: string; name: string; status?: string; path?: string | null }; +type IssueRecord = { id: string; title: string; status: string; projectId?: string | null }; +type GoalRecord = { id: string; title: string; status: string }; +type AgentRecord = { id: string; name: string; status: string }; +type HostIssueRecord = { + id: string; + title: string; + status: string; + priority?: string | null; + createdAt?: string; +}; +type HostHeartbeatRunRecord = { + id: string; + status: string; + invocationSource?: string | null; + triggerDetail?: string | null; + createdAt?: string; + startedAt?: string | null; + finishedAt?: string | null; + agentId?: string | null; +}; +type HostLiveRunRecord = HostHeartbeatRunRecord & { + agentName?: string | null; + issueId?: string | null; +}; + +type OverviewData = { + pluginId: string; + version: string; + capabilities: string[]; + config: Record; + runtimeLaunchers: Array<{ id: string; displayName: string; placementZone: string }>; + recentRecords: Array<{ id: string; source: string; message: string; createdAt: string; level: string; data?: unknown }>; + counts: { + companies: number; + projects: number; + issues: number; + goals: number; + agents: number; + entities: number; + }; + lastJob: unknown; + lastWebhook: unknown; + lastProcessResult: unknown; + streamChannels: Record; + safeCommands: Array<{ key: string; label: string; description: string }>; + manifest: { + jobs: Array<{ jobKey: string; displayName: string; schedule?: string }>; + webhooks: Array<{ endpointKey: string; displayName: string }>; + tools: Array<{ name: string; displayName: string; description: string }>; + }; +}; + +type EntityRecord = { + id: string; + entityType: string; + title: string | null; + status: string | null; + scopeKind: string; + scopeId: string | null; + externalId: string | null; + data: unknown; +}; + +type StateValueData = { + scope: { + scopeKind: string; + scopeId?: string; + namespace?: string; + stateKey: string; + }; + value: unknown; +}; + +type PluginConfigData = { + showSidebarEntry?: boolean; + showSidebarPanel?: boolean; + showProjectSidebarItem?: boolean; + showCommentAnnotation?: boolean; + showCommentContextMenuItem?: boolean; + enableWorkspaceDemos?: boolean; + enableProcessDemos?: boolean; +}; + +type CommentContextData = { + commentId: string; + issueId: string; + preview: string; + length: number; + copiedCount: number; +} | null; + +type ProcessResult = { + commandKey: string; + cwd: string; + code: number | null; + stdout: string; + stderr: string; + startedAt: string; + finishedAt: string; +}; + +const layoutStack: CSSProperties = { + display: "grid", + gap: "12px", +}; + +const cardStyle: CSSProperties = { + border: "1px solid var(--border)", + borderRadius: "12px", + padding: "14px", + background: "var(--card, transparent)", +}; + +const subtleCardStyle: CSSProperties = { + border: "1px solid color-mix(in srgb, var(--border) 75%, transparent)", + borderRadius: "10px", + padding: "12px", +}; + +const rowStyle: CSSProperties = { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "8px", +}; + +const sectionHeaderStyle: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "8px", + marginBottom: "10px", +}; + +const buttonStyle: CSSProperties = { + appearance: "none", + border: "1px solid var(--border)", + borderRadius: "999px", + background: "transparent", + color: "inherit", + padding: "6px 12px", + fontSize: "12px", + cursor: "pointer", +}; + +const primaryButtonStyle: CSSProperties = { + ...buttonStyle, + background: "var(--foreground)", + color: "var(--background)", + borderColor: "var(--foreground)", +}; + +function toneButtonStyle(tone: "success" | "warn" | "info"): CSSProperties { + if (tone === "success") { + return { + ...buttonStyle, + background: "color-mix(in srgb, #16a34a 18%, transparent)", + borderColor: "color-mix(in srgb, #16a34a 60%, var(--border))", + color: "#86efac", + }; + } + if (tone === "warn") { + return { + ...buttonStyle, + background: "color-mix(in srgb, #d97706 18%, transparent)", + borderColor: "color-mix(in srgb, #d97706 60%, var(--border))", + color: "#fcd34d", + }; + } + return { + ...buttonStyle, + background: "color-mix(in srgb, #2563eb 18%, transparent)", + borderColor: "color-mix(in srgb, #2563eb 60%, var(--border))", + color: "#93c5fd", + }; +} + +const inputStyle: CSSProperties = { + width: "100%", + border: "1px solid var(--border)", + borderRadius: "8px", + padding: "8px 10px", + background: "transparent", + color: "inherit", + fontSize: "12px", +}; + +const codeStyle: CSSProperties = { + margin: 0, + padding: "10px", + borderRadius: "8px", + border: "1px solid var(--border)", + background: "color-mix(in srgb, var(--muted, #888) 16%, transparent)", + overflowX: "auto", + fontSize: "11px", + lineHeight: 1.45, +}; + +const widgetGridStyle: CSSProperties = { + display: "grid", + gap: "12px", + gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", +}; + +const widgetStyle: CSSProperties = { + border: "1px solid var(--border)", + borderRadius: "14px", + padding: "14px", + display: "grid", + gap: "8px", + background: "color-mix(in srgb, var(--card, transparent) 72%, transparent)", +}; + +const mutedTextStyle: CSSProperties = { + fontSize: "12px", + opacity: 0.72, + 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); +} + +function getObjectString(value: unknown, key: string): string | null { + if (!value || typeof value !== "object") return null; + const next = (value as Record)[key]; + return typeof next === "string" ? next : null; +} + +function getObjectNumber(value: unknown, key: string): number | null { + if (!value || typeof value !== "object") return null; + const next = (value as Record)[key]; + return typeof next === "number" && Number.isFinite(next) ? next : null; +} + +function isKitchenSinkDemoCompany(company: CompanyRecord): boolean { + return company.name.startsWith("Kitchen Sink Demo"); +} + +function JsonBlock({ value }: { value: unknown }) { + return
    {JSON.stringify(value, null, 2)}
    ; +} + +function Section({ + title, + action, + children, +}: { + title: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
    +
    + {title} + {action} +
    +
    {children}
    +
    + ); +} + +function Pill({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function MiniWidget({ + title, + eyebrow, + children, +}: { + title: string; + eyebrow?: string; + children: ReactNode; +}) { + return ( +
    + {eyebrow ?
    {eyebrow}
    : null} + {title} +
    {children}
    +
    + ); +} + +function MiniList({ + items, + render, + empty, +}: { + items: unknown[]; + render: (item: unknown, index: number) => ReactNode; + empty: string; +}) { + if (items.length === 0) return
    {empty}
    ; + return ( +
    + {items.map((item, index) => ( +
    + {render(item, index)} +
    + ))} +
    + ); +} + +function StatusLine({ label, value }: { label: string; value: ReactNode }) { + return ( +
    + {label} +
    {value}
    +
    + ); +} + +function PaginatedDomainCard({ + title, + items, + totalCount, + empty, + onLoadMore, + render, +}: { + title: string; + items: unknown[]; + totalCount: number | null; + empty: string; + onLoadMore: () => void; + render: (item: unknown, index: number) => ReactNode; +}) { + const hasMore = totalCount !== null ? items.length < totalCount : false; + + return ( +
    +
    + {title} + {totalCount !== null ? {items.length} / {totalCount} : null} +
    + + {hasMore ? ( +
    + +
    + ) : null} +
    + ); +} + +function usePluginOverview(companyId: string | null) { + return usePluginData("overview", companyId ? { companyId } : {}); +} + +function usePluginConfigData() { + return usePluginData("plugin-config"); +} + +function hostFetchJson(path: string, init?: RequestInit): Promise { + return fetch(path, { + credentials: "include", + headers: { + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + ...init, + }).then(async (response) => { + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Request failed: ${response.status}`); + } + return await response.json() as T; + }); +} + +function useSettingsConfig() { + const [configJson, setConfigJson] = useState>({ ...DEFAULT_CONFIG }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + hostFetchJson<{ configJson?: Record | null } | null>(`/api/plugins/${PLUGIN_ID}/config`) + .then((result) => { + if (cancelled) return; + setConfigJson({ ...DEFAULT_CONFIG, ...(result?.configJson ?? {}) }); + setError(null); + }) + .catch((nextError) => { + if (cancelled) return; + setError(nextError instanceof Error ? nextError.message : String(nextError)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + async function save(nextConfig: Record) { + setSaving(true); + try { + await hostFetchJson(`/api/plugins/${PLUGIN_ID}/config`, { + method: "POST", + body: JSON.stringify({ configJson: nextConfig }), + }); + setConfigJson(nextConfig); + setError(null); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : String(nextError)); + throw nextError; + } finally { + setSaving(false); + } + } + + return { + configJson, + setConfigJson, + loading, + saving, + error, + save, + }; +} + +function CompactSurfaceSummary({ label, entityType }: { label: string; entityType?: string | null }) { + const context = useHostContext(); + const companyId = context.companyId; + const entityId = context.entityId; + const resolvedEntityType = entityType ?? context.entityType ?? null; + const entityQuery = usePluginData( + "entity-context", + companyId && entityId && resolvedEntityType + ? { companyId, entityId, entityType: resolvedEntityType } + : {}, + ); + const writeMetric = usePluginAction("write-metric"); + + return ( +
    +
    + {label} + {resolvedEntityType ? : null} +
    +
    + This surface demo shows the host context for the current mount point. The metric button records a demo counter so you can verify plugin metrics wiring from a contextual surface. +
    + + + {entityQuery.data ? : null} +
    + ); +} + +function KitchenSinkPageWidgets({ context }: { context: PluginPageProps["context"] }) { + const overview = usePluginOverview(context.companyId); + const toast = usePluginToast(); + const emitDemoEvent = usePluginAction("emit-demo-event"); + const startProgressStream = usePluginAction("start-progress-stream"); + const writeMetric = usePluginAction("write-metric"); + const progressStream = usePluginStream<{ step?: number; message?: string }>( + STREAM_CHANNELS.progress, + { companyId: context.companyId ?? undefined }, + ); + const [quickActionStatus, setQuickActionStatus] = useState<{ + title: string; + body: string; + tone: "info" | "success" | "warn" | "error"; + } | null>(null); + + useEffect(() => { + const latest = progressStream.events.at(-1); + if (!latest) return; + setQuickActionStatus({ + title: "Progress stream update", + body: latest.message ?? `Step ${latest.step ?? "?"}`, + tone: "info", + }); + }, [progressStream.events]); + + return ( +
    + +
    +
    Companies: {overview.data?.counts.companies ?? 0}
    +
    Projects: {overview.data?.counts.projects ?? 0}
    +
    Issues: {overview.data?.counts.issues ?? 0}
    +
    Agents: {overview.data?.counts.agents ?? 0}
    +
    +
    + + +
    + + + +
    +
    + + + +
    +
    +
    + Recent progress events: {progressStream.events.length} +
    + {quickActionStatus ? ( +
    +
    {quickActionStatus.title}
    +
    {quickActionStatus.body}
    +
    + ) : null} + {progressStream.events.length > 0 ? ( + + ) : null} +
    +
    + + +
    +
    Sidebar link and panel
    +
    Dashboard widget
    +
    Project link, tab, toolbar button, launcher
    +
    Issue tab, task view, toolbar button, launcher
    +
    Comment annotation and comment action
    +
    +
    + + +
    +
    Jobs: {overview.data?.manifest.jobs.length ?? 0}
    +
    Webhooks: {overview.data?.manifest.webhooks.length ?? 0}
    +
    Tools: {overview.data?.manifest.tools.length ?? 0}
    +
    Launchers: {overview.data?.runtimeLaunchers.length ?? 0}
    +
    +
    + + +
    + This updates as you use the worker demos below. +
    + +
    + +
    + ); +} + +function KitchenSinkIssueCrudDemo({ context }: { context: PluginPageProps["context"] }) { + const toast = usePluginToast(); + const [issues, setIssues] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [createTitle, setCreateTitle] = useState("Kitchen Sink demo issue"); + const [createDescription, setCreateDescription] = useState("Created from the Kitchen Sink embedded page."); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function loadIssues() { + if (!context.companyId) return; + setLoading(true); + try { + const result = await hostFetchJson(`/api/companies/${context.companyId}/issues`); + const nextIssues = result.slice(0, 8); + setIssues(nextIssues); + setDrafts( + Object.fromEntries( + nextIssues.map((issue) => [issue.id, { title: issue.title, status: issue.status }]), + ), + ); + setError(null); + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadIssues(); + }, [context.companyId]); + + async function handleCreate() { + if (!context.companyId || !createTitle.trim()) return; + try { + await hostFetchJson(`/api/companies/${context.companyId}/issues`, { + method: "POST", + body: JSON.stringify({ + title: createTitle.trim(), + description: createDescription.trim() || undefined, + status: "todo", + priority: "medium", + }), + }); + toast({ title: "Issue created", body: createTitle.trim(), tone: "success" }); + setCreateTitle("Kitchen Sink demo issue"); + setCreateDescription("Created from the Kitchen Sink embedded page."); + await loadIssues(); + } catch (nextError) { + toast({ title: "Issue create failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleSave(issueId: string) { + const draft = drafts[issueId]; + if (!draft) return; + try { + await hostFetchJson(`/api/issues/${issueId}`, { + method: "PATCH", + body: JSON.stringify({ + title: draft.title.trim(), + status: draft.status, + }), + }); + toast({ title: "Issue updated", body: draft.title.trim(), tone: "success" }); + await loadIssues(); + } catch (nextError) { + toast({ title: "Issue update failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleDelete(issueId: string) { + try { + await hostFetchJson(`/api/issues/${issueId}`, { method: "DELETE" }); + toast({ title: "Issue deleted", tone: "info" }); + await loadIssues(); + } catch (nextError) { + toast({ title: "Issue delete failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + return ( +
    +
    + This is a regular embedded React page inside Paperclip calling the board API directly. It creates, updates, and deletes issues for the current company. +
    + {!context.companyId ? ( +
    Select a company to use issue demos.
    + ) : ( + <> +
    + setCreateTitle(event.target.value)} placeholder="Issue title" /> + setCreateDescription(event.target.value)} placeholder="Issue description" /> + +
    + {loading ?
    Loading issues…
    : null} + {error ?
    {error}
    : null} +
    + {issues.map((issue) => { + const draft = drafts[issue.id] ?? { title: issue.title, status: issue.status }; + return ( +
    +
    + + setDrafts((current) => ({ + ...current, + [issue.id]: { ...draft, title: event.target.value }, + }))} + /> + + + +
    +
    + ); + })} + {!loading && issues.length === 0 ?
    No issues yet for this company.
    : null} +
    + + )} +
    + ); +} + +function KitchenSinkCompanyCrudDemo({ context }: { context: PluginPageProps["context"] }) { + const toast = usePluginToast(); + const [companies, setCompanies] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [newCompanyName, setNewCompanyName] = useState(`Kitchen Sink Demo ${new Date().toLocaleTimeString()}`); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function loadCompanies() { + setLoading(true); + try { + const result = await hostFetchJson>("/api/companies"); + setCompanies(result); + setDrafts( + Object.fromEntries( + result.map((company) => [company.id, { name: company.name, status: company.status ?? "active" }]), + ), + ); + setError(null); + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadCompanies(); + }, []); + + async function handleCreate() { + const trimmed = newCompanyName.trim(); + if (!trimmed) return; + const name = trimmed.startsWith("Kitchen Sink Demo") ? trimmed : `Kitchen Sink Demo ${trimmed}`; + try { + await hostFetchJson("/api/companies", { + method: "POST", + body: JSON.stringify({ + name, + description: "Created from the Kitchen Sink example plugin page.", + }), + }); + toast({ title: "Demo company created", body: name, tone: "success" }); + setNewCompanyName(`Kitchen Sink Demo ${Date.now()}`); + await loadCompanies(); + } catch (nextError) { + toast({ title: "Company create failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleSave(companyId: string) { + const draft = drafts[companyId]; + if (!draft) return; + try { + await hostFetchJson(`/api/companies/${companyId}`, { + method: "PATCH", + body: JSON.stringify({ + name: draft.name.trim(), + status: draft.status, + }), + }); + toast({ title: "Company updated", body: draft.name.trim(), tone: "success" }); + await loadCompanies(); + } catch (nextError) { + toast({ title: "Company update failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function handleDelete(company: CompanyRecord) { + try { + await hostFetchJson(`/api/companies/${company.id}`, { method: "DELETE" }); + toast({ title: "Demo company deleted", body: company.name, tone: "info" }); + await loadCompanies(); + } catch (nextError) { + toast({ title: "Company delete failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + const currentCompany = companies.find((company) => company.id === context.companyId) ?? null; + const demoCompanies = companies.filter(isKitchenSinkDemoCompany); + + return ( +
    +
    + The worker SDK currently exposes company reads. This page shows a pragmatic embedded-app pattern for broader board actions by calling the host REST API directly. +
    +
    +
    + Current Company + {currentCompany ? : null} +
    +
    {currentCompany?.name ?? "No current company selected"}
    +
    +
    + setNewCompanyName(event.target.value)} + placeholder="Kitchen Sink Demo Company" + /> + +
    + {loading ?
    Loading companies…
    : null} + {error ?
    {error}
    : null} +
    + {demoCompanies.map((company) => { + const draft = drafts[company.id] ?? { name: company.name, status: "active" }; + const isCurrent = company.id === context.companyId; + return ( +
    +
    + + setDrafts((current) => ({ + ...current, + [company.id]: { ...draft, name: event.target.value }, + }))} + /> + + + +
    + {isCurrent ?
    Current company cannot be deleted from this demo.
    : null} +
    + ); + })} + {!loading && demoCompanies.length === 0 ? ( +
    No demo companies yet. Create one above and manage it from this page.
    + ) : null} +
    +
    + ); +} + +function KitchenSinkTopRow({ context }: { context: PluginPageProps["context"] }) { + return ( +
    +
    +
    + Plugins can host their own React page and behave like a native company page. Kitchen Sink now uses this route as a practical demo app, then keeps the lower-level worker console below for the rest of the SDK surface. +
    +
    +
    +
    +
    + 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)} + +
    +
    +
    + This is the same Paperclip ASCII treatment used in onboarding, copied into the example plugin so the package stays self-contained. +
    + +
    +
    +
    + ); +} + +function KitchenSinkStorageDemo({ context }: { context: PluginPageProps["context"] }) { + const toast = usePluginToast(); + const stateKey = "revenue_clicker"; + const revenueState = usePluginData( + "state-value", + context.companyId + ? { scopeKind: "company", scopeId: context.companyId, stateKey } + : {}, + ); + const writeScopedState = usePluginAction("write-scoped-state"); + const deleteScopedState = usePluginAction("delete-scoped-state"); + + const currentValue = useMemo(() => { + const raw = revenueState.data?.value; + if (typeof raw === "number") return raw; + const parsed = Number(raw ?? 0); + return Number.isFinite(parsed) ? parsed : 0; + }, [revenueState.data?.value]); + + async function adjust(delta: number) { + if (!context.companyId) return; + try { + await writeScopedState({ + scopeKind: "company", + scopeId: context.companyId, + stateKey, + value: currentValue + delta, + }); + revenueState.refresh(); + } catch (nextError) { + toast({ title: "Storage write failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + async function reset() { + if (!context.companyId) return; + try { + await deleteScopedState({ + scopeKind: "company", + scopeId: context.companyId, + stateKey, + }); + toast({ title: "Revenue counter reset", tone: "info" }); + revenueState.refresh(); + } catch (nextError) { + toast({ title: "Storage reset failed", body: getErrorMessage(nextError), tone: "error" }); + } + } + + return ( +
    +
    + This clicker persists into plugin-scoped company storage. A real revenue plugin could store counters, sync cursors, or cached external IDs the same way. +
    + {!context.companyId ? ( +
    Select a company to use company-scoped plugin storage.
    + ) : ( + <> +
    +
    {currentValue}
    +
    Stored at `company/{context.companyId}/{stateKey}`
    +
    +
    + {[-10, -1, 1, 10].map((delta) => ( + + ))} + +
    + + + )} +
    + ); +} + +function KitchenSinkHostIntegrationDemo({ context }: { context: PluginPageProps["context"] }) { + const [liveRuns, setLiveRuns] = useState([]); + const [recentRuns, setRecentRuns] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function loadRuns() { + if (!context.companyId) return; + setLoading(true); + try { + const [nextLiveRuns, nextRecentRuns] = await Promise.all([ + hostFetchJson(`/api/companies/${context.companyId}/live-runs?minCount=5`), + hostFetchJson(`/api/companies/${context.companyId}/heartbeat-runs?limit=5`), + ]); + setLiveRuns(nextLiveRuns); + setRecentRuns(nextRecentRuns); + setError(null); + } catch (nextError) { + setError(getErrorMessage(nextError)); + } finally { + setLoading(false); + } + } + + useEffect(() => { + void loadRuns(); + }, [context.companyId]); + + return ( +
    +
    + Plugin pages can feel like native Paperclip pages. This section demonstrates host toasts, company-scoped routing, and reading live heartbeat data from the embedded page. +
    +
    +
    + Company Route + +
    +
    + This page is mounted as a real company route instead of living only under `/plugins/:pluginId`. +
    +
    + {!context.companyId ? ( +
    Select a company to read run data.
    + ) : ( +
    +
    +
    + Live Runs + +
    + {loading ?
    Loading run data…
    : null} + {error ?
    {error}
    : null} + { + const run = item as HostLiveRunRecord; + return ( +
    +
    + {run.status} + {run.agentName ? : null} +
    +
    {run.id}
    + {run.agentId ? ( + + Open run + + ) : null} +
    + ); + }} + /> +
    +
    + Recent Heartbeats + { + const run = item as HostHeartbeatRunRecord; + return ( +
    +
    + {run.status} + {run.invocationSource ? : null} +
    +
    {run.id}
    +
    + ); + }} + /> +
    +
    + )} +
    + ); +} + +function KitchenSinkEmbeddedApp({ context }: { context: PluginPageProps["context"] }) { + return ( +
    + + + + + +
    + ); +} + +function KitchenSinkConsole({ context }: { context: { companyId: string | null; companyPrefix?: string | null; projectId?: string | null; entityId?: string | null; entityType?: string | null } }) { + const companyId = context.companyId; + const overview = usePluginOverview(companyId); + const [companiesLimit, setCompaniesLimit] = useState(20); + const [projectsLimit, setProjectsLimit] = useState(20); + const [issuesLimit, setIssuesLimit] = useState(20); + const [goalsLimit, setGoalsLimit] = useState(20); + const companies = usePluginData("companies", { limit: companiesLimit }); + const projects = usePluginData("projects", companyId ? { companyId, limit: projectsLimit } : {}); + const issues = usePluginData("issues", companyId ? { companyId, limit: issuesLimit } : {}); + const goals = usePluginData("goals", companyId ? { companyId, limit: goalsLimit } : {}); + const agents = usePluginData("agents", companyId ? { companyId } : {}); + + const [issueTitle, setIssueTitle] = useState("Kitchen Sink demo issue"); + const [goalTitle, setGoalTitle] = useState("Kitchen Sink demo goal"); + const [stateScopeKind, setStateScopeKind] = useState("instance"); + const [stateScopeId, setStateScopeId] = useState(""); + const [stateNamespace, setStateNamespace] = useState(""); + const [stateKey, setStateKey] = useState("demo"); + const [stateValue, setStateValue] = useState("{\"hello\":\"world\"}"); + const [entityType, setEntityType] = useState("demo-record"); + const [entityTitle, setEntityTitle] = useState("Kitchen Sink Entity"); + const [entityScopeKind, setEntityScopeKind] = useState("instance"); + const [entityScopeId, setEntityScopeId] = useState(""); + const [selectedProjectId, setSelectedProjectId] = useState(""); + const [selectedIssueId, setSelectedIssueId] = useState(""); + const [selectedGoalId, setSelectedGoalId] = useState(""); + const [selectedAgentId, setSelectedAgentId] = useState(""); + const [httpUrl, setHttpUrl] = useState(DEFAULT_CONFIG.httpDemoUrl); + const [secretRef, setSecretRef] = useState(""); + const [metricName, setMetricName] = useState("manual"); + const [metricValue, setMetricValue] = useState("1"); + const [workspaceId, setWorkspaceId] = useState(""); + const [workspacePath, setWorkspacePath] = useState(DEFAULT_CONFIG.workspaceScratchFile); + const [workspaceContent, setWorkspaceContent] = useState("Kitchen Sink wrote this file."); + const [commandKey, setCommandKey] = useState(SAFE_COMMANDS[0]?.key ?? "pwd"); + const [toolMessage, setToolMessage] = useState("Hello from the Kitchen Sink tool"); + const [toolOutput, setToolOutput] = useState(null); + const [jobOutput, setJobOutput] = useState(null); + const [webhookOutput, setWebhookOutput] = useState(null); + const [result, setResult] = useState(null); + + const stateQuery = usePluginData("state-value", { + scopeKind: stateScopeKind, + scopeId: stateScopeId || undefined, + namespace: stateNamespace || undefined, + stateKey, + }); + const entityQuery = usePluginData("entities", { + entityType, + scopeKind: entityScopeKind, + scopeId: entityScopeId || undefined, + limit: 25, + }); + const workspaceQuery = usePluginData>( + "workspaces", + companyId && selectedProjectId ? { companyId, projectId: selectedProjectId } : {}, + ); + const progressStream = usePluginStream<{ step: number; total: number; message: string }>( + STREAM_CHANNELS.progress, + companyId ? { companyId } : undefined, + ); + const agentStream = usePluginStream<{ eventType: string; message: string | null }>( + STREAM_CHANNELS.agentChat, + companyId ? { companyId } : undefined, + ); + + const emitDemoEvent = usePluginAction("emit-demo-event"); + const createIssue = usePluginAction("create-issue"); + const advanceIssueStatus = usePluginAction("advance-issue-status"); + const createGoal = usePluginAction("create-goal"); + const advanceGoalStatus = usePluginAction("advance-goal-status"); + const writeScopedState = usePluginAction("write-scoped-state"); + const deleteScopedState = usePluginAction("delete-scoped-state"); + const upsertEntity = usePluginAction("upsert-entity"); + const writeActivity = usePluginAction("write-activity"); + const writeMetric = usePluginAction("write-metric"); + const httpFetch = usePluginAction("http-fetch"); + const resolveSecret = usePluginAction("resolve-secret"); + const runProcess = usePluginAction("run-process"); + const readWorkspaceFile = usePluginAction("read-workspace-file"); + const writeWorkspaceScratch = usePluginAction("write-workspace-scratch"); + const startProgressStream = usePluginAction("start-progress-stream"); + const invokeAgent = usePluginAction("invoke-agent"); + const pauseAgent = usePluginAction("pause-agent"); + const resumeAgent = usePluginAction("resume-agent"); + const askAgent = usePluginAction("ask-agent"); + + useEffect(() => { + setProjectsLimit(20); + setIssuesLimit(20); + setGoalsLimit(20); + }, [companyId]); + + useEffect(() => { + if (!selectedProjectId && projects.data?.[0]?.id) setSelectedProjectId(projects.data[0].id); + }, [projects.data, selectedProjectId]); + + useEffect(() => { + if (!selectedIssueId && issues.data?.[0]?.id) setSelectedIssueId(issues.data[0].id); + }, [issues.data, selectedIssueId]); + + useEffect(() => { + if (!selectedGoalId && goals.data?.[0]?.id) setSelectedGoalId(goals.data[0].id); + }, [goals.data, selectedGoalId]); + + useEffect(() => { + if (!selectedAgentId && agents.data?.[0]?.id) setSelectedAgentId(agents.data[0].id); + }, [agents.data, selectedAgentId]); + + useEffect(() => { + if (!workspaceId && workspaceQuery.data?.[0]?.id) setWorkspaceId(workspaceQuery.data[0].id); + }, [workspaceId, workspaceQuery.data]); + + const projectRef = selectedProjectId || context.projectId || ""; + + async function refreshAll() { + overview.refresh(); + projects.refresh(); + issues.refresh(); + goals.refresh(); + agents.refresh(); + stateQuery.refresh(); + entityQuery.refresh(); + workspaceQuery.refresh(); + } + + async function executeTool(name: string) { + if (!companyId || !selectedAgentId || !projectRef) { + setToolOutput({ error: "Select a company, project, and agent first." }); + return; + } + try { + const toolName = `${PLUGIN_ID}:${name}`; + const body = + name === TOOL_NAMES.echo + ? { message: toolMessage } + : name === TOOL_NAMES.createIssue + ? { title: issueTitle, description: "Created through the tool dispatcher demo." } + : {}; + const response = await hostFetchJson(`/api/plugins/tools/execute`, { + method: "POST", + body: JSON.stringify({ + tool: toolName, + parameters: body, + runContext: { + agentId: selectedAgentId, + runId: `kitchen-sink-${Date.now()}`, + companyId, + projectId: projectRef, + }, + }), + }); + setToolOutput(response); + await refreshAll(); + } catch (error) { + setToolOutput({ error: error instanceof Error ? error.message : String(error) }); + } + } + + async function fetchJobsAndTrigger() { + try { + const jobsResponse = await hostFetchJson>(`/api/plugins/${PLUGIN_ID}/jobs`); + const job = jobsResponse.find((entry) => entry.jobKey === JOB_KEYS.heartbeat) ?? jobsResponse[0]; + if (!job) { + setJobOutput({ error: "No plugin jobs returned by the host." }); + return; + } + const triggerResult = await hostFetchJson(`/api/plugins/${PLUGIN_ID}/jobs/${job.id}/trigger`, { + method: "POST", + }); + setJobOutput({ jobs: jobsResponse, triggerResult }); + overview.refresh(); + } catch (error) { + setJobOutput({ error: error instanceof Error ? error.message : String(error) }); + } + } + + async function sendWebhook() { + try { + const response = await hostFetchJson(`/api/plugins/${PLUGIN_ID}/webhooks/${WEBHOOK_KEYS.demo}`, { + method: "POST", + body: JSON.stringify({ + source: "kitchen-sink-ui", + sentAt: new Date().toISOString(), + }), + }); + setWebhookOutput(response); + overview.refresh(); + } catch (error) { + setWebhookOutput({ error: error instanceof Error ? error.message : String(error) }); + } + } + + return ( +
    +
    refreshAll()}>Refresh} + > +
    + + + + {context.entityType ? : null} +
    + {overview.data ? ( + <> +
    + + + + + + +
    + + + ) : ( +
    Loading overview…
    + )} +
    + +
    +
    + Open plugin page + {projectRef ? ( + + Open project tab + + ) : null} + {selectedIssueId ? ( + + Open selected issue + + ) : null} +
    + +
    + +
    +
    + setCompaniesLimit((current) => current + 20)} + render={(item) => { + const company = item as CompanyRecord; + return
    {company.name} ({company.id.slice(0, 8)})
    ; + }} + /> + setProjectsLimit((current) => current + 20)} + render={(item) => { + const project = item as ProjectRecord; + return
    {project.name} ({project.status ?? "unknown"})
    ; + }} + /> + setIssuesLimit((current) => current + 20)} + render={(item) => { + const issue = item as IssueRecord; + return
    {issue.title} ({issue.status})
    ; + }} + /> + setGoalsLimit((current) => current + 20)} + render={(item) => { + const goal = item as GoalRecord; + return
    {goal.title} ({goal.status})
    ; + }} + /> +
    +
    + +
    +
    +
    { + event.preventDefault(); + if (!companyId) return; + void createIssue({ companyId, projectId: selectedProjectId || undefined, title: issueTitle }) + .then((next) => { + setResult(next); + return refreshAll(); + }) + .catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) })); + }} + > + Create issue + setIssueTitle(event.target.value)} /> + +
    +
    { + event.preventDefault(); + if (!companyId || !selectedIssueId) return; + void advanceIssueStatus({ companyId, issueId: selectedIssueId, status: "in_review" }) + .then((next) => { + setResult(next); + return refreshAll(); + }) + .catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) })); + }} + > + Advance selected issue + + +
    +
    { + event.preventDefault(); + if (!companyId) return; + void createGoal({ companyId, title: goalTitle }) + .then((next) => { + setResult(next); + return refreshAll(); + }) + .catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) })); + }} + > + Create goal + setGoalTitle(event.target.value)} /> + +
    +
    { + event.preventDefault(); + if (!companyId || !selectedGoalId) return; + void advanceGoalStatus({ companyId, goalId: selectedGoalId, status: "active" }) + .then((next) => { + setResult(next); + return refreshAll(); + }) + .catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) })); + }} + > + Advance selected goal + + +
    +
    +
    + +
    +
    +
    { + event.preventDefault(); + void writeScopedState({ + scopeKind: stateScopeKind, + scopeId: stateScopeId || undefined, + namespace: stateNamespace || undefined, + stateKey, + value: stateValue, + }) + .then((next) => { + setResult(next); + stateQuery.refresh(); + }) + .catch((error) => setResult({ error: error instanceof Error ? error.message : String(error) })); + }} + > + State + setStateScopeKind(event.target.value)} placeholder="scopeKind" /> + setStateScopeId(event.target.value)} placeholder="scopeId (optional)" /> + setStateNamespace(event.target.value)} placeholder="namespace (optional)" /> + setStateKey(event.target.value)} placeholder="stateKey" /> +