diff --git a/AGENTS.md b/AGENTS.md index dad6684f..bdfa3e5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ Before making changes, read in this order: - `ui/`: React + Vite board UI - `packages/db/`: Drizzle schema, migrations, DB clients - `packages/shared/`: shared types, constants, validators, API path constants +- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.) +- `packages/adapter-utils/`: shared adapter utilities +- `packages/plugins/`: plugin system packages - `doc/`: operational and product docs ## 4. Dev Setup (Auto DB) diff --git a/README.md b/README.md index 391a0feb..42f24cd1 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required. npx paperclipai onboard --yes ``` +If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. + Or manually: ```bash @@ -234,16 +236,27 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. ## Roadmap -- ⚪ Get OpenClaw onboarding easier -- ⚪ Get cloud agents working e.g. Cursor / e2b agents -- ⚪ ClipMart - buy and sell entire agent companies -- ⚪ Easy agent configurations / easier to understand -- ⚪ Better support for harness engineering -- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc) -- ⚪ Better docs +- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc) +- ✅ Get OpenClaw / claw-style agent employees +- ✅ companies.sh - import and export entire organizations +- ✅ Easy AGENTS.md configurations +- ✅ Skills Manager +- ✅ Scheduled Routines +- ✅ Better Budgeting +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ⚪ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App
+## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + ## Contributing We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details. diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..1826e376 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,292 @@ +

+ Paperclip — runs your business +

+ +

+ Quickstart · + Docs · + GitHub · + Discord +

+ +

+ MIT License + Stars + Discord +

+ +
+ +
+ +
+ +
+ +## What is Paperclip? + +# Open-source orchestration for zero-human companies + +**If OpenClaw is an _employee_, Paperclip is the _company_** + +Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard. + +It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination. + +**Manage business goals, not pull requests.** + +| | Step | Example | +| ------ | --------------- | ------------------------------------------------------------------ | +| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ | +| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. | +| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. | + +
+ +> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds. + +
+ +
+ + + + + + + + + + +
Works
with
OpenClaw
OpenClaw
Claude
Claude Code
Codex
Codex
Cursor
Cursor
Bash
Bash
HTTP
HTTP
+ +If it can receive a heartbeat, it's hired. + +
+ +
+ +## Paperclip is right for you if + +- ✅ You want to build **autonomous AI companies** +- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal +- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing +- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed +- ✅ You want to **monitor costs** and enforce budgets +- ✅ You want a process for managing agents that **feels like using a task manager** +- ✅ You want to manage your autonomous businesses **from your phone** + +
+ +## Features + + + + + + + + + + + + + + + + + +
+

🔌 Bring Your Own Agent

+Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired. +
+

🎯 Goal Alignment

+Every task traces back to the company mission. Agents know what to do and why. +
+

💓 Heartbeats

+Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart. +
+

💰 Cost Control

+Monthly budgets per agent. When they hit the limit, they stop. No runaway costs. +
+

🏢 Multi-Company

+One deployment, many companies. Complete data isolation. One control plane for your portfolio. +
+

🎫 Ticket System

+Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log. +
+

🛡️ Governance

+You're the board. Approve hires, override strategy, pause or terminate any agent — at any time. +
+

📊 Org Chart

+Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description. +
+

📱 Mobile Ready

+Monitor and manage your autonomous businesses from anywhere. +
+ +
+ +## Problems Paperclip solves + +| Without Paperclip | With Paperclip | +| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. | +| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. | +| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. | +| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. | +| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. | +| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. | + +
+ +## Why Paperclip is special + +Paperclip handles the hard orchestration details correctly. + +| | | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. | +| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. | +| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. | +| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. | +| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. | +| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. | +| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. | + +
+ +## What Paperclip is not + +| | | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Not a chatbot.** | Agents have jobs, not chat windows. | +| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. | +| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. | +| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. | +| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. | +| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. | + +
+ +## Quickstart + +Open source. Self-hosted. No Paperclip account required. + +```bash +npx paperclipai onboard --yes +``` + +If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. + +Or manually: + +```bash +git clone https://github.com/paperclipai/paperclip.git +cd paperclip +pnpm install +pnpm dev +``` + +This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required. + +> **Requirements:** Node.js 20+, pnpm 9.15+ + +
+ +## FAQ + +**What does a typical setup look like?** +Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest. + +If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it. + +**Can I run multiple companies?** +Yes. A single deployment can run an unlimited number of companies with complete data isolation. + +**How is Paperclip different from agents like OpenClaw or Claude Code?** +Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability. + +**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?** +Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you. + +(Bring-your-own-ticket-system is on the Roadmap) + +**Do agents run continuously?** +By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates. + +
+ +## Development + +```bash +pnpm dev # Full dev (API + UI, watch mode) +pnpm dev:once # Full dev without file watching +pnpm dev:server # Server only +pnpm build # Build all +pnpm typecheck # Type checking +pnpm test:run # Run tests +pnpm db:generate # Generate DB migration +pnpm db:migrate # Apply migrations +``` + +See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide. + +
+ +## Roadmap + +- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc) +- ✅ Get OpenClaw / claw-style agent employees +- ✅ companies.sh - import and export entire organizations +- ✅ Easy AGENTS.md configurations +- ✅ Skills Manager +- ✅ Scheduled Routines +- ✅ Better Budgeting +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ⚪ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App + +
+ +## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + +## Contributing + +We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details. + +
+ +## Community + +- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community +- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests +- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC + +
+ +## License + +MIT © 2026 Paperclip + +## Star History + +[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left) + +
+ +--- + +

+ +

+ +

+ Open source under MIT. Built for people who want to run companies, not babysit agents. +

diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 27334105..c543249e 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -6,33 +6,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { createStoredZipArchive } from "./helpers/zip.js"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - const execFileAsync = promisify(execFile); type ServerProcess = ReturnType; -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - async function getAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = net.createServer(); @@ -53,30 +35,13 @@ async function getAvailablePort(): Promise { }); } -async function startTempDatabase() { - const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; - const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db"); - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - - return { connectionString, dataDir, instance }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) { @@ -265,26 +230,23 @@ async function waitForServer( ); } -describe("paperclipai company import/export e2e", () => { +describeEmbeddedPostgres("paperclipai company import/export e2e", () => { let tempRoot = ""; let configPath = ""; let exportDir = ""; let apiBase = ""; let serverProcess: ServerProcess | null = null; - let dbDataDir = ""; - let dbInstance: EmbeddedPostgresInstance | null = null; + let tempDb: Awaited> | null = null; beforeAll(async () => { tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-")); configPath = path.join(tempRoot, "config", "config.json"); exportDir = path.join(tempRoot, "exported-company"); - const db = await startTempDatabase(); - dbDataDir = db.dataDir; - dbInstance = db.instance; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-"); const port = await getAvailablePort(); - writeTestConfig(configPath, tempRoot, port, db.connectionString); + writeTestConfig(configPath, tempRoot, port, tempDb.connectionString); apiBase = `http://127.0.0.1:${port}`; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -294,7 +256,7 @@ describe("paperclipai company import/export e2e", () => { ["paperclipai", "run", "--config", configPath], { cwd: repoRoot, - env: createServerEnv(configPath, port, db.connectionString), + env: createServerEnv(configPath, port, tempDb.connectionString), stdio: ["ignore", "pipe", "pipe"], }, ); @@ -311,10 +273,7 @@ describe("paperclipai company import/export e2e", () => { afterAll(async () => { await stopServerProcess(serverProcess); - await dbInstance?.stop(); - if (dbDataDir) { - rmSync(dbDataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); if (tempRoot) { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/cli/src/__tests__/helpers/embedded-postgres.ts b/cli/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..4318162a --- /dev/null +++ b/cli/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,6 @@ +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "@paperclipai/db"; diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts new file mode 100644 index 00000000..a5ffe44a --- /dev/null +++ b/cli/src/__tests__/onboard.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { onboard } from "../commands/onboard.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createExistingConfigFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-")); + const runtimeRoot = path.join(root, "runtime"); + const configPath = path.join(root, ".paperclip", "config.json"); + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-03-29T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(runtimeRoot, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(runtimeRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(runtimeRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(runtimeRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(runtimeRoot, "secrets", "master.key"), + }, + }, + }; + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + + return { configPath, configText: fs.readFileSync(configPath, "utf8") }; +} + +describe("onboard", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("preserves an existing config when rerun without flags", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); + + it("preserves an existing config when rerun with --yes", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath, yes: true, invokedByRun: true }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); +}); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ca48b001..3c2079d2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -344,6 +344,87 @@ describe("worktree helpers", () => { } }); + it("avoids ports already claimed by sibling worktree instance configs", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); + const repoRoot = path.join(tempRoot, "repo"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(siblingInstanceRoot, { recursive: true }); + fs.writeFileSync( + path.join(siblingInstanceRoot, "config.json"), + JSON.stringify( + { + ...buildSourceConfig(), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(siblingInstanceRoot, "logs"), + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: ["localhost"], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(siblingInstanceRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + ); + + process.chdir(repoRoot); + await worktreeInitCommand({ + seed: false, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: homeDir, + }); + + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8")); + expect(config.server.port).toBe(3102); + expect(config.database.embeddedPostgresPort).not.toBe(54330); + expect(config.database.embeddedPostgresPort).not.toBe(config.server.port); + expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("defaults the seed source config to the current repo-local Paperclip config", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); const repoRoot = path.join(tempRoot, "repo"); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 523484f3..d470354f 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -244,11 +244,12 @@ export async function onboard(opts: OnboardOptions): Promise { ), ); + let existingConfig: PaperclipConfig | null = null; if (configExists(opts.config)) { - p.log.message(pc.dim(`${configPath} exists, updating config`)); + p.log.message(pc.dim(`${configPath} exists`)); try { - readConfig(opts.config); + existingConfig = readConfig(opts.config); } catch (err) { p.log.message( pc.yellow( @@ -258,6 +259,76 @@ export async function onboard(opts: OnboardOptions): Promise { } } + if (existingConfig) { + p.log.message( + pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."), + ); + p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`)); + + const jwtSecret = ensureAgentJwtSecret(configPath); + const envFilePath = resolveAgentJwtEnvFile(configPath); + if (jwtSecret.created) { + p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`); + } else { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } + + const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath); + if (keyResult.status === "created") { + p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`); + } else if (keyResult.status === "existing") { + p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); + } + + p.note( + [ + "Existing config preserved", + `Database: ${existingConfig.database.mode}`, + existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured", + `Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`, + `Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`, + `Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`, + `Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`, + `Storage: ${existingConfig.storage.provider}`, + `Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`, + "Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured", + ].join("\n"), + "Configuration ready", + ); + + p.note( + [ + `Run: ${pc.cyan("paperclipai run")}`, + `Reconfigure later: ${pc.cyan("paperclipai configure")}`, + `Diagnose setup: ${pc.cyan("paperclipai doctor")}`, + ].join("\n"), + "Next commands", + ); + + let shouldRunNow = opts.run === true || opts.yes === true; + if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) { + const answer = await p.confirm({ + message: "Start Paperclip now?", + initialValue: true, + }); + if (!p.isCancel(answer)) { + shouldRunNow = answer; + } + } + + if (shouldRunNow && !opts.invokedByRun) { + process.env.PAPERCLIP_OPEN_ON_LISTEN = "true"; + const { runCommand } = await import("./run.js"); + await runCommand({ config: configPath, repair: true, yes: true }); + return; + } + + p.outro("Existing Paperclip setup is ready."); + return; + } + let setupMode: SetupMode = "quickstart"; if (opts.yes) { p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults.")); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 7a2bd127..65e74849 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -41,6 +41,8 @@ import { projects, runDatabaseBackup, runDatabaseRestore, + createEmbeddedPostgresLogBuffer, + formatEmbeddedPostgresError, } from "@paperclipai/db"; import type { Command } from "commander"; import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; @@ -465,6 +467,62 @@ async function findAvailablePort(preferredPort: number, reserved = new Set; + databasePorts: Set; +} { + const serverPorts = new Set(); + const databasePorts = new Set(); + const configPaths = new Set(); + const instancesDir = path.resolve(homeDir, "instances"); + if (existsSync(instancesDir)) { + for (const entry of readdirSync(instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === currentInstanceId) continue; + + const configPath = path.resolve(instancesDir, entry.name, "config.json"); + if (existsSync(configPath)) { + configPaths.add(configPath); + } + } + } + + const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd); + if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) { + for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json"); + if (existsSync(configPath)) { + configPaths.add(configPath); + } + } + } + + for (const configPath of configPaths) { + try { + const config = readConfig(configPath); + if (config?.server.port) { + serverPorts.add(config.server.port); + } + if (config?.database.mode === "embedded-postgres") { + databasePorts.add(config.database.embeddedPostgresPort); + } + } catch { + // Ignore malformed sibling configs. + } + } + + return { serverPorts, databasePorts }; +} + function detectGitBranchName(cwd: string): string | null { try { const value = execFileSync("git", ["branch", "--show-current"], { @@ -750,6 +808,7 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P } const port = await findAvailablePort(preferredPort); + const logBuffer = createEmbeddedPostgresLogBuffer(); const instance = new EmbeddedPostgres({ databaseDir: dataDir, user: "paperclip", @@ -757,17 +816,31 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P port, persistent: true, initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, + onLog: logBuffer.append, + onError: logBuffer.append, }); if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { - await instance.initialise(); + try { + await instance.initialise(); + } catch (error) { + throw formatEmbeddedPostgresError(error, { + fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`, + recentLogs: logBuffer.getRecentLogs(), + }); + } } if (existsSync(postmasterPidFile)) { rmSync(postmasterPidFile, { force: true }); } - await instance.start(); + try { + await instance.start(); + } catch (error) { + throw formatEmbeddedPostgresError(error, { + fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`, + recentLogs: logBuffer.getRecentLogs(), + }); + } return { port, @@ -886,10 +959,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { rmSync(paths.instanceRoot, { recursive: true, force: true }); } + const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd); const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); - const serverPort = await findAvailablePort(preferredServerPort); + const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts); const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); - const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + const databasePort = await findAvailablePort( + preferredDbPort, + new Set([...claimedPorts.databasePorts, serverPort]), + ); const targetConfig = buildWorktreeConfig({ sourceConfig, paths, diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7d98cd6d..1516c84a 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -39,6 +39,17 @@ This starts: `pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching. +`pnpm dev:once` auto-applies pending local migrations by default before starting the dev server. + +`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate. + +Inspect or stop the current repo's managed dev runner: + +```sh +pnpm dev:list +pnpm dev:stop +``` + `pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server. Tailscale/private-auth dev mode: @@ -134,6 +145,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins - `~/.paperclip/instances/default/companies//codex-home` +If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary). + ## Worktree-local Instances When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. @@ -206,6 +219,17 @@ paperclipai worktree init --from-data-dir ~/.paperclip paperclipai worktree init --force ``` +Repair an already-created repo-managed worktree and reseed its isolated instance from the main default install: + +```sh +cd ~/.paperclip/worktrees/PAP-884-ai-commits-component +pnpm paperclipai worktree init --force --seed-mode minimal \ + --name PAP-884-ai-commits-component \ + --from-config ~/.paperclip/instances/default/config.json +``` + +That rewrites the worktree-local `.paperclip/config.json` + `.paperclip/.env`, recreates the isolated instance under `~/.paperclip-worktrees/instances//`, and preserves the git worktree contents themselves. + **`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 | diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 50a1930e..83a6c4a7 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -51,10 +51,9 @@ Public packages are discovered from: - `packages/` - `server/` +- `ui/` - `cli/` -`ui/` is ignored because it is private. - The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which: - finds all public packages @@ -65,6 +64,57 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts Those rewrites are temporary. The working tree is restored after publish or dry-run. +## `@paperclipai/ui` packaging + +The UI package publishes prebuilt static assets, not the source workspace. + +The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that: + +- keeps the release-managed `name` and `version` +- publishes only `dist/` +- omits the source-only dependency graph from downstream installs + +After packing or publishing, `postpack` restores the development manifest automatically. + +### Manual first publish for `@paperclipai/ui` + +If you need to publish only the UI package once by hand, use the real package name: + +- `@paperclipai/ui` + +Recommended flow from the repo root: + +```bash +# optional sanity check: this 404s until the first publish exists +npm view @paperclipai/ui version + +# make sure the dist payload is fresh +pnpm --filter @paperclipai/ui build + +# confirm your local npm auth before the real publish +npm whoami + +# safe preview of the exact publish payload +cd ui +pnpm publish --dry-run --no-git-checks --access public + +# real publish +pnpm publish --no-git-checks --access public +``` + +Notes: + +- Publish from `ui/`, not the repo root. +- `prepack` automatically rewrites `ui/package.json` to the lean publish manifest, and `postpack` restores the dev manifest after the command finishes. +- If `npm view @paperclipai/ui version` already returns the same version that is in [`ui/package.json`](../ui/package.json), do not republish. Bump the version or use the normal repo-wide release flow in [`scripts/release.sh`](../scripts/release.sh). + +If the first real publish returns npm `E404`, check npm-side prerequisites before retrying: + +- `npm whoami` must succeed first. An expired or missing npm login will block the publish. +- For an organization-scoped package like `@paperclipai/ui`, the `paperclipai` npm organization must exist and the publisher must be a member with permission to publish to that scope. +- The initial publish must include `--access public` for a public scoped package. +- npm also requires either account 2FA for publishing or a granular token that is allowed to bypass 2FA. + ## Version formats Paperclip uses calendar versions: @@ -135,6 +185,7 @@ This is the fastest way to restore the default install path if a stable release - [`scripts/build-npm.sh`](../scripts/build-npm.sh) - [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs) +- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) - [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs) - [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs) - [`doc/RELEASING.md`](RELEASING.md) diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index d987a316..25982892 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -35,6 +35,7 @@ At minimum that includes: - `paperclipai` - `@paperclipai/server` +- `@paperclipai/ui` - public packages under `packages/` ### 2.1. In npm, open each package settings page diff --git a/doc/SPEC.md b/doc/SPEC.md index 82315bce..6a7039ca 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -186,17 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an ### Execution Adapters -Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters: +Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include: -| Adapter | Mechanism | Example | -| -------------------- | ----------------------- | --------------------------------------------- | -| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | -| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | -| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | -| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval | -| `hermes_local` | Hermes agent process | Local Hermes agent | +| Adapter | Mechanism | Example | +| ---------------- | -------------------------- | -------------------------------------------------- | +| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | +| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | +| `claude_local` | Local Claude Code process | Claude Code heartbeat worker | +| `codex_local` | Local Codex process | Codex CLI heartbeat worker | +| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker | +| `pi_local` | Local Pi process | Pi CLI heartbeat worker | +| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker | +| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | +| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker | -The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). +The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). ### Adapter Interface @@ -376,7 +380,7 @@ Flow: | Layer | Technology | | -------- | ------------------------------------------------------------ | | Frontend | React + Vite | -| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) | +| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) | | Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) | | Auth | [Better Auth](https://www.better-auth.com/) | @@ -406,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization ### Work Artifacts -Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope. +Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline. ### Open Questions @@ -476,15 +480,14 @@ Each is a distinct page/route: - [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill - [ ] **Default CEO** — strategic planning, delegation, board communication - [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API -- [ ] **REST API** — full API for agent interaction (Hono) +- [ ] **REST API** — full API for agent interaction (Express) - [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views - [ ] **Agent auth** — connection string generation with URL + key + instructions - [ ] **One-command dev setup** — embedded PGlite, everything local -- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter) +- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters) ### Not V1 -- Template export/import - Knowledge base - a future plugin - Advanced governance models (hiring budgets, multi-member boards) - Revenue/expense tracking beyond token costs - a future plugin @@ -509,7 +512,7 @@ Things Paperclip explicitly does **not** do: - **Not a SaaS** — single-tenant, self-hosted - **Not opinionated about Agent implementation** — any language, any framework, any runtime - **Not automatically self-healing** — surfaces problems, doesn't silently fix them -- **Does not manage work artifacts** — no repo management, no deployment, no file systems +- **Does not manage delivery infrastructure** — no repo management, no deployment, no file systems (but does manage task-linked documents and attachments) - **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed - **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core. diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 44b879d7..3216b5e5 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -20,9 +20,12 @@ 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 | +| [Gemini Local](/adapters/gemini-local) | `gemini_local` | Runs Gemini CLI locally (experimental — adapter package exists, not yet in stable type enum) | | OpenCode Local | `opencode_local` | Runs OpenCode CLI locally (multi-provider `provider/model`) | -| OpenClaw | `openclaw` | Sends wake payloads to an OpenClaw webhook | +| Hermes Local | `hermes_local` | Runs Hermes CLI locally | +| Cursor | `cursor` | Runs Cursor in background mode | +| Pi Local | `pi_local` | Runs an embedded Pi agent locally | +| OpenClaw Gateway | `openclaw_gateway` | Connects to an OpenClaw gateway endpoint | | [Process](/adapters/process) | `process` | Executes arbitrary shell commands | | [HTTP](/adapters/http) | `http` | Sends webhooks to external agents | @@ -55,7 +58,7 @@ Three registries consume these modules: ## Choosing an Adapter -- **Need a coding agent?** Use `claude_local`, `codex_local`, `gemini_local`, or `opencode_local` +- **Need a coding agent?** Use `claude_local`, `codex_local`, `opencode_local`, or `hermes_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/agents-runtime.md b/docs/agents-runtime.md index bda72729..f3672723 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -1,7 +1,7 @@ # Agent Runtime Guide -Status: User-facing guide -Last updated: 2026-02-17 +Status: User-facing guide +Last updated: 2026-03-26 Audience: Operators setting up and running agents in Paperclip ## 1. What this system does @@ -32,14 +32,19 @@ If an agent is already running, new wakeups are merged (coalesced) instead of la ## 3.1 Adapter choice -Common choices: +Built-in adapters: - `claude_local`: runs your local `claude` CLI - `codex_local`: runs your local `codex` CLI +- `opencode_local`: runs your local `opencode` CLI +- `hermes_local`: runs your local `hermes` CLI +- `cursor`: runs Cursor in background mode +- `pi_local`: runs an embedded Pi agent locally +- `openclaw_gateway`: connects to an OpenClaw gateway endpoint - `process`: generic shell command adapter - `http`: calls an external HTTP endpoint -For `claude_local` and `codex_local`, Paperclip assumes the CLI is already installed and authenticated on the host machine. +For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. ## 3.2 Runtime behavior @@ -69,6 +74,8 @@ You can set: Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values. +> **Note:** `bootstrapPromptTemplate` is deprecated and should not be used for new agents. Existing configs that use it will continue to work but should be migrated to the managed instructions bundle system. + ## 4. Session resume behavior Paperclip stores session IDs for resumable adapters. @@ -133,7 +140,7 @@ If the connection drops, the UI reconnects automatically. If runs fail repeatedly: -1. Check adapter command availability (`claude`/`codex` installed and logged in). +1. Check adapter command availability (e.g. `claude`/`codex`/`opencode`/`hermes` installed and logged in). 2. Verify `cwd` exists and is accessible. 3. Inspect run error + stderr excerpt, then full log. 4. Confirm timeout is not too low. @@ -166,9 +173,9 @@ Start with least privilege where possible, and avoid exposing secrets in broad r ## 10. Minimal setup checklist -1. Choose adapter (`claude_local` or `codex_local`). -2. Set `cwd` to the target workspace. -3. Add bootstrap + normal prompt templates. +1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). +2. Set `cwd` to the target workspace (for local adapters). +3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle. 4. Configure heartbeat policy (timer and/or assignment wakeups). 5. Trigger a manual wakeup. 6. Confirm run succeeds and session/token usage is recorded. diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 7dc5cd6a..448ab7bb 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -33,6 +33,8 @@ Interactive first-time setup: pnpm paperclipai onboard ``` +If Paperclip is already configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to change settings on an existing install. + First prompt: 1. `Quickstart` (recommended): local defaults (embedded database, no LLM provider, local disk storage, default secrets) @@ -50,6 +52,8 @@ Non-interactive defaults + immediate start (opens browser on server listen): pnpm paperclipai onboard --yes ``` +On an existing install, `--yes` now preserves the current config and just starts Paperclip with that setup. + ## `paperclipai doctor` Health checks with optional auto-repair: diff --git a/docs/docs.json b/docs/docs.json index a13a1e77..f87809af 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -46,6 +46,8 @@ "guides/board-operator/managing-agents", "guides/board-operator/org-structure", "guides/board-operator/managing-tasks", + "guides/board-operator/execution-workspaces-and-runtime-services", + "guides/board-operator/delegation", "guides/board-operator/approvals", "guides/board-operator/costs-and-budgets", "guides/board-operator/activity-log", diff --git a/docs/guides/board-operator/delegation.md b/docs/guides/board-operator/delegation.md new file mode 100644 index 00000000..7096632a --- /dev/null +++ b/docs/guides/board-operator/delegation.md @@ -0,0 +1,122 @@ +--- +title: How Delegation Works +summary: How the CEO breaks down goals into tasks and assigns them to agents +--- + +Delegation is one of Paperclip's most powerful features. You set company goals, and the CEO agent automatically breaks them into tasks and assigns them to the right agents. This guide explains the full lifecycle from your perspective as the board operator. + +## The Delegation Lifecycle + +When you create a company goal, the CEO doesn't just acknowledge it — it builds a plan and mobilizes the team: + +``` +You set a company goal + → CEO wakes up on heartbeat + → CEO proposes a strategy (creates an approval for you) + → You approve the strategy + → CEO breaks goals into tasks and assigns them to reports + → Reports wake up (heartbeat triggered by assignment) + → Reports execute work and update task status + → CEO monitors progress, unblocks, and escalates + → You see results in the dashboard and activity log +``` + +Each step is traceable. Every task links back to the goal through a parent hierarchy, so you can always see why work is happening. + +## What You Need to Do + +Your role is strategic oversight, not task management. Here's what the delegation model expects from you: + +1. **Set clear company goals.** The CEO works from these. Specific, measurable goals produce better delegation. "Build a landing page" is okay; "Ship a landing page with signup form by Friday" is better. + +2. **Approve the CEO's strategy.** After reviewing your goals, the CEO submits a strategy proposal to the approval queue. Review it, then approve, reject, or request revisions. + +3. **Approve hire requests.** When the CEO needs more capacity (e.g., a frontend engineer to build the landing page), it submits a hire request. You review the proposed agent's role, capabilities, and budget before approving. + +4. **Monitor progress.** Use the dashboard and activity log to track how work is flowing. Check task status, agent activity, and completion rates. + +5. **Intervene only when things stall.** If progress stops, check these in order: + - Is an approval pending in your queue? + - Is an agent paused or in an error state? + - Is the CEO's budget exhausted (above 80%, it focuses on critical tasks only)? + +## What the CEO Does Automatically + +You do **not** need to tell the CEO to engage specific agents. After you approve its strategy, the CEO: + +- **Breaks goals into concrete tasks** with clear descriptions, priorities, and acceptance criteria +- **Assigns tasks to the right agent** based on role and capabilities (e.g., engineering tasks go to the CTO or engineers, marketing tasks go to the CMO) +- **Creates subtasks** when work needs to be decomposed further +- **Hires new agents** when the team lacks capacity for a goal (subject to your approval) +- **Monitors progress** on each heartbeat, checking task status and unblocking reports +- **Escalates to you** when it encounters something it can't resolve — budget issues, blocked approvals, or strategic ambiguity + +## Common Delegation Patterns + +### Flat Hierarchy (Small Teams) + +For small companies with 3-5 agents, the CEO delegates directly to each report: + +``` +CEO + ├── CTO (engineering tasks) + ├── CMO (marketing tasks) + └── Designer (design tasks) +``` + +The CEO assigns tasks directly. Each agent works independently and reports status back. + +### Three-Level Hierarchy (Larger Teams) + +For larger organizations, managers delegate further down the chain: + +``` +CEO + ├── CTO + │ ├── Backend Engineer + │ └── Frontend Engineer + └── CMO + └── Content Writer +``` + +The CEO assigns high-level tasks to the CTO and CMO. They break those into subtasks and assign them to their own reports. You only interact with the CEO — the rest happens automatically. + +### Hire-on-Demand + +The CEO can start as the only agent and hire as work requires: + +1. You set a goal that needs engineering work +2. The CEO proposes a strategy that includes hiring a CTO +3. You approve the hire +4. The CEO assigns engineering tasks to the new CTO +5. As scope grows, the CTO may request to hire engineers + +This pattern lets you start small and scale the team based on actual work, not upfront planning. + +## Troubleshooting + +### "Why isn't the CEO delegating?" + +If you've set a goal but nothing is happening, check these common causes: + +| Check | What to look for | +|-------|-----------------| +| **Approval queue** | The CEO may have submitted a strategy or hire request that's waiting for your approval. This is the most common reason. | +| **Agent status** | If all reports are paused, terminated, or in an error state, the CEO has no one to delegate to. Check the Agents page. | +| **Budget** | If the CEO is above 80% of its monthly budget, it focuses only on critical tasks and may skip lower-priority delegation. | +| **Goals** | If no company goals are set, the CEO has nothing to work from. Create a goal first. | +| **Heartbeat** | Is the CEO's heartbeat enabled and running? Check the agent detail page for recent heartbeat history. | +| **Agent instructions** | The CEO's delegation behavior is driven by its `AGENTS.md` instructions file. Open the CEO agent's detail page and verify that its instructions path is set and that the file includes delegation directives (subtask creation, hiring, assignment). If AGENTS.md is missing or doesn't mention delegation, the CEO won't know to break down goals and assign work. | + +### "Do I have to tell the CEO to engage engineering and marketing?" + +**No.** The CEO will delegate automatically after you approve its strategy. It knows the org chart and assigns tasks based on each agent's role and capabilities. You set the goal and approve the plan — the CEO handles task breakdown and assignment. + +### "A task seems stuck" + +If a specific task isn't progressing: + +1. Check the task's comment thread — the assigned agent may have posted a blocker +2. Check if the task is in `blocked` status — read the blocker comment to understand why +3. Check the assigned agent's status — it may be paused or over budget +4. If the agent is stuck, you can reassign the task or add a comment with guidance diff --git a/docs/guides/board-operator/execution-workspaces-and-runtime-services.md b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md new file mode 100644 index 00000000..285d701a --- /dev/null +++ b/docs/guides/board-operator/execution-workspaces-and-runtime-services.md @@ -0,0 +1,68 @@ +--- +title: Execution Workspaces And Runtime Services +summary: How project runtime configuration, execution workspaces, and issue runs fit together +--- + +This guide documents the intended runtime model for projects, execution workspaces, and issue runs in Paperclip. + +## Project runtime configuration + +You can define how to run a project on the project workspace itself. + +- Project workspace runtime config describes how to run services for that project checkout. +- This is the default runtime configuration that child execution workspaces may inherit. +- Defining the config does not start anything by itself. + +## Manual runtime control + +Runtime services are manually controlled from the UI. + +- Project workspace runtime services are started and stopped from the project workspace UI. +- Execution workspace runtime services are started and stopped from the execution workspace UI. +- Paperclip does not automatically start or stop these runtime services as part of issue execution. +- Paperclip also does not automatically restart workspace runtime services on server boot. + +## Execution workspace inheritance + +Execution workspaces isolate code and runtime state from the project primary workspace. + +- An isolated execution workspace has its own checkout path, branch, and local runtime instance. +- The runtime configuration may inherit from the linked project workspace by default. +- The execution workspace may override that runtime configuration with its own workspace-specific settings. +- The inherited configuration answers "how to run the service", but the running process is still specific to that execution workspace. + +## Issues and execution workspaces + +Issues are attached to execution workspace behavior, not to automatic runtime management. + +- An issue may create a new execution workspace when you choose an isolated workspace mode. +- An issue may reuse an existing execution workspace when you choose reuse. +- Multiple issues may intentionally share one execution workspace so they can work against the same branch and running runtime services. +- Assigning or running an issue does not automatically start or stop runtime services for that workspace. + +## Execution workspace lifecycle + +Execution workspaces are durable until a human closes them. + +- The UI can archive an execution workspace. +- Closing an execution workspace stops its runtime services and cleans up its workspace artifacts when allowed. +- Shared workspaces that point at the project primary checkout are treated more conservatively during cleanup than disposable isolated workspaces. + +## Resolved workspace logic during heartbeat runs + +Heartbeat still resolves a workspace for the run, but that is about code location and session continuity, not runtime-service control. + +1. Heartbeat resolves a base workspace for the run. +2. Paperclip realizes the effective execution workspace, including creating or reusing a worktree when needed. +3. Paperclip persists execution-workspace metadata such as paths, refs, and provisioning settings. +4. Heartbeat passes the resolved code workspace to the agent run. +5. Workspace runtime services remain manual UI-managed controls rather than automatic heartbeat-managed services. + +## Current implementation guarantees + +With the current implementation: + +- Project workspace runtime config is the fallback for execution workspace UI controls. +- Execution workspace runtime overrides are stored on the execution workspace. +- Heartbeat runs do not auto-start workspace runtime services. +- Server startup does not auto-restart workspace runtime services. diff --git a/docs/guides/board-operator/managing-agents.md b/docs/guides/board-operator/managing-agents.md index 453b967f..4850222d 100644 --- a/docs/guides/board-operator/managing-agents.md +++ b/docs/guides/board-operator/managing-agents.md @@ -29,7 +29,7 @@ Create agents from the Agents page. Each agent requires: Common adapter choices: - `claude_local` / `codex_local` / `opencode_local` for local coding agents -- `openclaw` / `http` for webhook-based external agents +- `openclaw_gateway` / `http` for webhook-based external agents - `process` for generic local command execution For `opencode_local`, configure an explicit `adapterConfig.model` (`provider/model`). diff --git a/docs/start/core-concepts.md b/docs/start/core-concepts.md index 3b6b1ac4..33de5806 100644 --- a/docs/start/core-concepts.md +++ b/docs/start/core-concepts.md @@ -1,9 +1,9 @@ --- title: Core Concepts -summary: Companies, agents, issues, heartbeats, and governance +summary: Companies, agents, issues, delegation, heartbeats, and governance --- -Paperclip organizes autonomous AI work around five key concepts. +Paperclip organizes autonomous AI work around six key concepts. ## Company @@ -50,6 +50,17 @@ Terminal states: `done`, `cancelled`. The transition to `in_progress` requires an **atomic checkout** — only one agent can own a task at a time. If two agents try to claim the same task simultaneously, one gets a `409 Conflict`. +## Delegation + +The CEO is the primary delegator. When you set company goals, the CEO: + +1. Creates a strategy and submits it for your approval +2. Breaks approved goals into tasks +3. Assigns tasks to agents based on their role and capabilities +4. Hires new agents when needed (subject to your approval) + +You don't need to manually assign every task — set the goals and let the CEO organize the work. You approve key decisions (strategy, hiring) and monitor progress. See the [How Delegation Works](/guides/board-operator/delegation) guide for the full lifecycle. + ## Heartbeats Agents don't run continuously. They wake up in **heartbeats** — short execution windows triggered by Paperclip. diff --git a/docs/start/quickstart.md b/docs/start/quickstart.md index 1ad30fcd..2abe538b 100644 --- a/docs/start/quickstart.md +++ b/docs/start/quickstart.md @@ -13,6 +13,8 @@ npx paperclipai onboard --yes This walks you through setup, configures your environment, and gets Paperclip running. +If you already have a Paperclip install, rerunning `onboard` keeps your current config and data paths intact. Use `paperclipai configure` if you want to edit settings. + To start Paperclip again later: ```sh diff --git a/package.json b/package.json index 749cc8d0..311a092f 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,11 @@ "private": true, "type": "module", "scripts": { - "dev": "node scripts/dev-runner.mjs watch", - "dev:watch": "node scripts/dev-runner.mjs watch", - "dev:once": "node scripts/dev-runner.mjs dev", + "dev": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", + "dev:watch": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts watch", + "dev:once": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-runner.ts dev", + "dev:list": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts list", + "dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop", "dev:server": "pnpm --filter @paperclipai/server dev", "dev:ui": "pnpm --filter @paperclipai/ui dev", "build": "pnpm -r build", @@ -32,7 +34,8 @@ "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval", "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", - "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed" + "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", + "metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 12989f72..4a5affdf 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -201,6 +201,33 @@ export function redactEnvForLogs(env: Record): Record, + options: { + runtimeEnv?: NodeJS.ProcessEnv | Record; + includeRuntimeKeys?: string[]; + resolvedCommand?: string | null; + resolvedCommandEnvKey?: string; + } = {}, +): Record { + const merged: Record = { ...env }; + const runtimeEnv = options.runtimeEnv ?? {}; + + for (const key of options.includeRuntimeKeys ?? []) { + if (key in merged) continue; + const value = runtimeEnv[key]; + if (typeof value !== "string" || value.length === 0) continue; + merged[key] = value; + } + + const resolvedCommand = options.resolvedCommand?.trim(); + if (resolvedCommand) { + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + } + + return redactEnvForLogs(merged); +} + export function buildPaperclipEnv(agent: { id: string; companyId: string }): Record { const resolveHostForUrl = (rawHost: string): string => { const host = rawHost.trim(); @@ -269,6 +296,10 @@ async function resolveCommandPath(command: string, cwd: string, env: NodeJS.Proc return null; } +export async function resolveCommandForLogs(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise { + return (await resolveCommandPath(command, cwd, env)) ?? command; +} + function quoteForCmd(arg: string) { if (!arg.length) return '""'; const escaped = arg.replace(/"/g, '""'); diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ce89e0e8..9337fad0 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -287,6 +287,12 @@ export interface ServerAdapterModule { * without knowing provider-specific credential paths or API shapes. */ getQuotaWindows?: () => Promise; + /** + * Optional: detect the currently configured model from local config files. + * Returns the detected model/provider and the config source, or null if + * the adapter does not support detection or no config is found. + */ + detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>; } // --------------------------------------------------------------------------- diff --git a/packages/adapters/claude-local/src/cli/format-event.ts b/packages/adapters/claude-local/src/cli/format-event.ts index 08423d6e..13263be6 100644 --- a/packages/adapters/claude-local/src/cli/format-event.ts +++ b/packages/adapters/claude-local/src/cli/format-event.ts @@ -17,6 +17,27 @@ function asErrorText(value: unknown): string { } } +function printToolResult(block: Record): void { + const isError = block.is_error === true; + let text = ""; + if (typeof block.content === "string") { + text = block.content; + } else if (Array.isArray(block.content)) { + const parts: string[] = []; + for (const part of block.content) { + if (typeof part !== "object" || part === null || Array.isArray(part)) continue; + const record = part as Record; + if (typeof record.text === "string") parts.push(record.text); + } + text = parts.join("\n"); + } + + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + if (text) { + console.log((isError ? pc.red : pc.gray)(text)); + } +} + export function printClaudeStreamEvent(raw: string, debug: boolean): void { const line = raw.trim(); if (!line) return; @@ -51,6 +72,9 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void { if (blockType === "text") { const text = typeof block.text === "string" ? block.text : ""; if (text) console.log(pc.green(`assistant: ${text}`)); + } else if (blockType === "thinking") { + const text = typeof block.thinking === "string" ? block.thinking : ""; + if (text) console.log(pc.gray(`thinking: ${text}`)); } else if (blockType === "tool_use") { const name = typeof block.name === "string" ? block.name : "unknown"; console.log(pc.yellow(`tool_call: ${name}`)); @@ -62,6 +86,22 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void { return; } + if (type === "user") { + const message = + typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message) + ? (parsed.message as Record) + : {}; + const content = Array.isArray(message.content) ? message.content : []; + for (const blockRaw of content) { + if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue; + const block = blockRaw as Record; + if (typeof block.type === "string" && block.type === "tool_result") { + printToolResult(block); + } + } + return; + } + if (type === "result") { const usage = typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage) diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index b28ae180..41c0693f 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -26,7 +26,7 @@ Core fields: - extraArgs (string[], optional): additional CLI args - env (object, optional): KEY=VALUE environment variables - workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? } -- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env +- workspaceRuntime (object, optional): reserved for workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats Operational fields: - timeoutSec (number, optional): run timeout in seconds diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 8ac1d7ee..c7d6c6a8 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -14,10 +14,11 @@ import { buildPaperclipEnv, readPaperclipRuntimeSkillEntries, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, + resolveCommandForLogs, renderTemplate, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -68,11 +69,13 @@ interface ClaudeExecutionInput { interface ClaudeRuntimeConfig { command: string; + resolvedCommand: string; cwd: string; workspaceId: string | null; workspaceRepoUrl: string | null; workspaceRepoRef: string | null; env: Record; + loggedEnv: Record; timeoutSec: number; graceSec: number; extraArgs: string[]; @@ -236,6 +239,12 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise { if (idx === args.length - 1 && value !== "-") return ``; return value; }), - env: redactEnvForLogs(env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts new file mode 100644 index 00000000..85d1e44c --- /dev/null +++ b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts @@ -0,0 +1,85 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { ChildProcess } from "node:child_process"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +const { mockSpawn } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const cp = await importOriginal(); + return { + ...cp, + spawn: (...args: Parameters) => mockSpawn(...args) as ReturnType, + }; +}); + +import { getQuotaWindows } from "./quota.js"; + +function createChildThatErrorsOnMicrotask(err: Error): ChildProcess { + const child = new EventEmitter() as ChildProcess; + const stream = Object.assign(new EventEmitter(), { + setEncoding: () => {}, + }); + Object.assign(child, { + stdout: stream, + stderr: Object.assign(new EventEmitter(), { setEncoding: () => {} }), + stdin: { write: vi.fn(), end: vi.fn() }, + kill: vi.fn(), + }); + queueMicrotask(() => { + child.emit("error", err); + }); + return child; +} + +describe("CodexRpcClient spawn failures", () => { + let previousCodexHome: string | undefined; + let isolatedCodexHome: string | undefined; + + beforeEach(() => { + mockSpawn.mockReset(); + // After the RPC path fails, getQuotaWindows() calls readCodexToken() which + // reads $CODEX_HOME/auth.json (default ~/.codex). Point CODEX_HOME at an + // empty temp directory so we never hit real host auth or the WHAM network. + previousCodexHome = process.env.CODEX_HOME; + isolatedCodexHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-codex-spawn-test-")); + process.env.CODEX_HOME = isolatedCodexHome; + }); + + afterEach(() => { + if (isolatedCodexHome) { + try { + fs.rmSync(isolatedCodexHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } + isolatedCodexHome = undefined; + } + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } + }); + + it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => { + const enoent = Object.assign(new Error("spawn codex ENOENT"), { + code: "ENOENT", + errno: -2, + syscall: "spawn codex", + path: "codex", + }); + mockSpawn.mockImplementation(() => createChildThatErrorsOnMicrotask(enoent)); + + const result = await getQuotaWindows(); + + expect(result.ok).toBe(false); + expect(result.windows).toEqual([]); + expect(result.error).toContain("Codex app-server"); + expect(result.error).toContain("spawn codex ENOENT"); + }); +}); diff --git a/packages/adapters/codex-local/src/server/quota.ts b/packages/adapters/codex-local/src/server/quota.ts index 7bc771e4..3c0ac3bf 100644 --- a/packages/adapters/codex-local/src/server/quota.ts +++ b/packages/adapters/codex-local/src/server/quota.ts @@ -432,6 +432,13 @@ class CodexRpcClient { } this.pending.clear(); }); + this.proc.on("error", (err: Error) => { + for (const request of this.pending.values()) { + clearTimeout(request.timer); + request.reject(err); + } + this.pending.clear(); + }); } private onStdout(chunk: string) { diff --git a/packages/adapters/codex-local/vitest.config.ts b/packages/adapters/codex-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/codex-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index df339690..ed41754a 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -9,12 +9,13 @@ import { asStringArray, parseObject, buildPaperclipEnv, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, readPaperclipRuntimeSkillEntries, + resolveCommandForLogs, resolvePaperclipDesiredSkillNames, removeMaintainerOnlySkillSymlinks, renderTemplate, @@ -271,6 +272,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise ( index === args.length - 1 ? `` : value )), - env: redactEnvForLogs(env), + env: loggedEnv, prompt, promptMetrics, context, diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index 195edfbf..1bdf66f2 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -31,7 +31,7 @@ Gateway connect identity fields: Request behavior fields: - payloadTemplate (object, optional): additional fields merged into gateway agent params -- workspaceRuntime (object, optional): desired runtime service intents; Paperclip forwards these in a standardized paperclip.workspaceRuntime block for remote execution environments +- workspaceRuntime (object, optional): reserved workspace runtime metadata; workspace runtime services are manually controlled from the workspace UI and are not auto-started by heartbeats - timeoutSec (number, optional): adapter timeout in seconds (default 120) - waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000) - autoPairOnFirstConnect (boolean, optional): on first "pairing required", attempt device.pair.list/device.pair.approve via shared auth, then retry once (default true) @@ -45,7 +45,7 @@ Standard outbound payload additions: - paperclip (object): standardized Paperclip context added to every gateway agent request - paperclip.workspace (object, optional): resolved execution workspace for this run - paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run -- paperclip.workspaceRuntime (object, optional): normalized runtime service intent config for the workspace +- paperclip.workspaceRuntime (object, optional): reserved workspace runtime metadata when explicitly supplied outside normal heartbeat execution Standard result metadata supported: - meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 67fbdb4b..c8f37a81 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -1,7 +1,15 @@ export const type = "opencode_local"; export const label = "OpenCode (local)"; -export const models: Array<{ id: string; label: string }> = []; +export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex"; + +export const models: Array<{ id: string; label: string }> = [ + { id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL }, + { id: "openai/gpt-5.4", label: "openai/gpt-5.4" }, + { id: "openai/gpt-5.2", label: "openai/gpt-5.2" }, + { id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" }, + { id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" }, +]; export const agentConfigurationDoc = `# opencode_local agent configuration @@ -21,7 +29,8 @@ 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 - model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5) -- variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max) +- variant (string, optional): provider-specific reasoning/profile variant passed as --variant (for example minimal|low|medium|high|xhigh|max) +- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs - promptTemplate (string, optional): run prompt template - command (string, optional): defaults to "opencode" - extraArgs (string[], optional): additional CLI args @@ -40,4 +49,7 @@ Notes: - The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \ writing an opencode.json config file into the project working directory. Model \ selection is passed via the --model CLI flag instead. +- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \ + runtime config with \`permission.external_directory=allow\` so headless runs do \ + not stall on approval prompts. `; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 788ad835..7c034c69 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -10,11 +10,12 @@ import { parseObject, buildPaperclipEnv, joinPromptSections, - redactEnvForLogs, + buildInvocationEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePaperclipSkillSymlink, ensurePathInEnv, + resolveCommandForLogs, renderTemplate, runChildProcess, readPaperclipRuntimeSkillEntries, @@ -23,6 +24,7 @@ import { import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; +import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -177,231 +179,245 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", - ), - ); - await ensureCommandResolvable(command, cwd, runtimeEnv); - - await ensureOpenCodeModelConfiguredAndAvailable({ - model, - command, - cwd, - env: 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( - "stdout", - `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); + try { + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ); - } + await ensureCommandResolvable(command, cwd, runtimeEnv); + const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv); + const loggedEnv = buildInvocationEnvForLogs(preparedRuntimeConfig.env, { + runtimeEnv, + includeRuntimeKeys: ["HOME"], + resolvedCommand, + }); - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const resolvedInstructionsFilePath = instructionsFilePath - ? path.resolve(cwd, instructionsFilePath) - : ""; - const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; - let instructionsPrefix = ""; - if (resolvedInstructionsFilePath) { - try { - const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); - instructionsPrefix = - `${instructionsContents}\n\n` + - `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + - `Resolve any relative file references from ${instructionsDir}.\n\n`; - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - await onLog( - "stdout", - `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, - ); - } - } - - const commandNotes = (() => { - if (!resolvedInstructionsFilePath) return [] as string[]; - if (instructionsPrefix.length > 0) { - return [ - `Loaded agent instructions from ${resolvedInstructionsFilePath}`, - `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, - ]; - } - return [ - `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, - ]; - })(); - - 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 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"]; - if (resumeSessionId) args.push("--session", resumeSessionId); - if (model) args.push("--model", model); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - return args; - }; - - const runAttempt = async (resumeSessionId: string | null) => { - const args = buildArgs(resumeSessionId); - if (onMeta) { - await onMeta({ - adapterType: "opencode_local", - command, - cwd, - commandNotes, - commandArgs: [...args, ``], - env: redactEnvForLogs(env), - prompt, - promptMetrics, - context, - }); - } - - const proc = await runChildProcess(runId, command, args, { + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, cwd, env: runtimeEnv, - stdin: prompt, - timeoutSec, - graceSec, - onSpawn, - onLog, }); - return { - proc, - rawStderr: proc.stderr, - parsed: parseOpenCodeJsonl(proc.stdout), - }; - }; - const toResult = ( - attempt: { - proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; - rawStderr: string; - parsed: ReturnType; - }, - clearSessionOnMissingSession = false, - ): AdapterExecutionResult => { - if (attempt.proc.timedOut) { - return { - exitCode: attempt.proc.exitCode, - signal: attempt.proc.signal, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - clearSession: clearSessionOnMissingSession, - }; + 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( + "stdout", + `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); } - const resolvedSessionId = - attempt.parsed.sessionId ?? - (clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null); - const resolvedSessionParams = resolvedSessionId - ? ({ - sessionId: resolvedSessionId, - cwd, - ...(workspaceId ? { workspaceId } : {}), - ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), - ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), - } as Record) - : null; + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const resolvedInstructionsFilePath = instructionsFilePath + ? path.resolve(cwd, instructionsFilePath) + : ""; + const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (resolvedInstructionsFilePath) { + try { + const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stdout", + `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, + ); + } + } - const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const rawExitCode = attempt.proc.exitCode; - const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; - const fallbackErrorMessage = - parsedError || - stderrLine || - `OpenCode exited with code ${synthesizedExitCode ?? -1}`; - const modelId = model || null; + const commandNotes = (() => { + const notes = [...preparedRuntimeConfig.notes]; + if (!resolvedInstructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`); + notes.push( + `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); - return { - exitCode: synthesizedExitCode, - signal: attempt.proc.signal, - timedOut: false, - errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, - usage: { - inputTokens: attempt.parsed.usage.inputTokens, - outputTokens: attempt.parsed.usage.outputTokens, - cachedInputTokens: attempt.parsed.usage.cachedInputTokens, - }, - sessionId: resolvedSessionId, - sessionParams: resolvedSessionParams, - sessionDisplayId: resolvedSessionId, - provider: parseModelProvider(modelId), - biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)), - model: modelId, - billingType: "unknown", - costUsd: attempt.parsed.costUsd, - resultJson: { - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - }, - summary: attempt.parsed.summary, - clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), + 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 prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, }; - }; - const initial = await runAttempt(sessionId); - const initialFailed = - !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); - if ( - sessionId && - initialFailed && - isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) - ) { - await onLog( - "stdout", - `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, - ); - const retry = await runAttempt(null); - return toResult(retry, true); + const buildArgs = (resumeSessionId: string | null) => { + const args = ["run", "--format", "json"]; + if (resumeSessionId) args.push("--session", resumeSessionId); + if (model) args.push("--model", model); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "opencode_local", + command: resolvedCommand, + cwd, + commandNotes, + commandArgs: [...args, ``], + env: loggedEnv, + prompt, + promptMetrics, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env: runtimeEnv, + stdin: prompt, + timeoutSec, + graceSec, + onSpawn, + onLog, + }); + return { + proc, + rawStderr: proc.stderr, + parsed: parseOpenCodeJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: string; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = + attempt.parsed.sessionId ?? + (clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? 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 rawExitCode = attempt.proc.exitCode; + const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; + const fallbackErrorMessage = + parsedError || + stderrLine || + `OpenCode exited with code ${synthesizedExitCode ?? -1}`; + const modelId = model || null; + + return { + exitCode: synthesizedExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: attempt.parsed.usage.inputTokens, + outputTokens: attempt.parsed.usage.outputTokens, + cachedInputTokens: attempt.parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: parseModelProvider(modelId), + biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)), + model: modelId, + billingType: "unknown", + costUsd: attempt.parsed.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), + }; + }; + + const initial = await runAttempt(sessionId); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); + if ( + sessionId && + initialFailed && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stdout", + `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true); + } + + return toResult(initial); + } finally { + await preparedRuntimeConfig.cleanup(); } - - return toResult(initial); } diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts new file mode 100644 index 00000000..c5c396ac --- /dev/null +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; + +const cleanupPaths = new Set(); + +afterEach(async () => { + await Promise.all( + [...cleanupPaths].map(async (filepath) => { + await fs.rm(filepath, { recursive: true, force: true }); + cleanupPaths.delete(filepath); + }), + ); +}); + +async function makeConfigHome(initialConfig?: Record) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-")); + cleanupPaths.add(root); + const configDir = path.join(root, "opencode"); + await fs.mkdir(configDir, { recursive: true }); + if (initialConfig) { + await fs.writeFile( + path.join(configDir, "opencode.json"), + `${JSON.stringify(initialConfig, null, 2)}\n`, + "utf8", + ); + } + return root; +} + +describe("prepareOpenCodeRuntimeConfig", () => { + it("injects an external_directory allow rule by default", async () => { + const configHome = await makeConfigHome({ + permission: { + read: "allow", + }, + theme: "system", + }); + + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + + expect(prepared.env.XDG_CONFIG_HOME).not.toBe(configHome); + const runtimeConfig = JSON.parse( + await fs.readFile( + path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), + "utf8", + ), + ) as Record; + expect(runtimeConfig).toMatchObject({ + theme: "system", + permission: { + read: "allow", + external_directory: "allow", + }, + }); + + await prepared.cleanup(); + cleanupPaths.delete(prepared.env.XDG_CONFIG_HOME); + await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow(); + }); + + it("respects explicit opt-out", async () => { + const configHome = await makeConfigHome(); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome }, + config: { dangerouslySkipPermissions: false }, + }); + + expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome }); + expect(prepared.notes).toEqual([]); + await prepared.cleanup(); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts new file mode 100644 index 00000000..bc903e83 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { asBoolean } from "@paperclipai/adapter-utils/server-utils"; + +type PreparedOpenCodeRuntimeConfig = { + env: Record; + notes: string[]; + cleanup: () => Promise; +}; + +function resolveXdgConfigHome(env: Record): string { + return ( + (typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()) || + (typeof process.env.XDG_CONFIG_HOME === "string" && process.env.XDG_CONFIG_HOME.trim()) || + path.join(os.homedir(), ".config") + ); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readJsonObject(filepath: string): Promise> { + try { + const raw = await fs.readFile(filepath, "utf8"); + const parsed = JSON.parse(raw); + return isPlainObject(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +export async function prepareOpenCodeRuntimeConfig(input: { + env: Record; + config: Record; +}): Promise { + const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true); + if (!skipPermissions) { + return { + env: input.env, + notes: [], + cleanup: async () => {}, + }; + } + + const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode"); + const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-")); + const runtimeConfigDir = path.join(runtimeConfigHome, "opencode"); + const runtimeConfigPath = path.join(runtimeConfigDir, "opencode.json"); + + await fs.mkdir(runtimeConfigDir, { recursive: true }); + try { + await fs.cp(sourceConfigDir, runtimeConfigDir, { + recursive: true, + force: true, + errorOnExist: false, + dereference: false, + }); + } catch (err) { + if ((err as NodeJS.ErrnoException | null)?.code !== "ENOENT") { + throw err; + } + } + + const existingConfig = await readJsonObject(runtimeConfigPath); + const existingPermission = isPlainObject(existingConfig.permission) + ? existingConfig.permission + : {}; + const nextConfig = { + ...existingConfig, + permission: { + ...existingPermission, + external_directory: "allow", + }, + }; + await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8"); + + return { + env: { + ...input.env, + XDG_CONFIG_HOME: runtimeConfigHome, + }, + notes: [ + "Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.", + ], + cleanup: async () => { + await fs.rm(runtimeConfigHome, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index ad3957d1..1d6ef459 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -4,6 +4,7 @@ import type { AdapterEnvironmentTestResult, } from "@paperclipai/adapter-utils"; import { + asBoolean, asString, asStringArray, parseObject, @@ -14,6 +15,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { parseOpenCodeJsonl } from "./parse.js"; +import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -92,224 +94,236 @@ export async function testEnvironment( // Prevent OpenCode from writing an opencode.json into the working directory. env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; - const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); - - const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); - if (cwdInvalid) { + const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); + if (asBoolean(config.dangerouslySkipPermissions, true)) { checks.push({ - code: "opencode_command_skipped", - level: "warn", - message: "Skipped command check because working directory validation failed.", - detail: command, + code: "opencode_headless_permissions_enabled", + level: "info", + message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.", }); - } else { - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + } + try { + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })); + + const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); + if (cwdInvalid) { checks.push({ - code: "opencode_command_resolvable", - level: "info", - message: `Command is executable: ${command}`, - }); - } catch (err) { - checks.push({ - code: "opencode_command_unresolvable", - level: "error", - message: err instanceof Error ? err.message : "Command is not executable", + code: "opencode_command_skipped", + level: "warn", + message: "Skipped command check because working directory validation failed.", detail: command, }); - } - } - - const canRunProbe = - checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); - - let modelValidationPassed = false; - const configuredModel = asString(config.model, "").trim(); - - if (canRunProbe && configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { + } else { + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); checks.push({ - code: "opencode_models_discovered", + code: "opencode_command_resolvable", level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + message: `Command is executable: ${command}`, }); - } else { + } catch (err) { checks.push({ - code: "opencode_models_empty", + code: "opencode_command_unresolvable", level: "error", - message: "OpenCode returned no models.", - hint: "Run `opencode models` and verify provider authentication.", - }); - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "error", - message: errMsg || "OpenCode model discovery failed.", - hint: "Run `opencode models` manually to verify provider auth and config.", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, }); } } - } else if (canRunProbe && !configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { - checks.push({ - code: "opencode_models_discovered", - level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, - }); + + const canRunProbe = + checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); + + let modelValidationPassed = false; + const configuredModel = asString(config.model, "").trim(); + + if (canRunProbe && configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } else { + checks.push({ + code: "opencode_models_empty", + level: "error", + message: "OpenCode returned no models.", + hint: "Run `opencode models` and verify provider authentication.", + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "error", + message: errMsg || "OpenCode model discovery failed.", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "warn", - message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", - hint: "Run `opencode models` manually to verify provider auth and config.", - }); + } else if (canRunProbe && !configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "warn", + message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } } } - } - const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); - if (!configuredModel && !modelUnavailable) { - // No model configured – skip model requirement if no model-related checks exist - } else if (configuredModel && canRunProbe) { - try { - await ensureOpenCodeModelConfiguredAndAvailable({ - model: configuredModel, - command, - cwd, - env: runtimeEnv, - }); - checks.push({ - code: "opencode_model_configured", - level: "info", - message: `Configured model: ${configuredModel}`, - }); - modelValidationPassed = true; - } catch (err) { - checks.push({ - code: "opencode_model_invalid", - level: "error", - message: err instanceof Error ? err.message : "Configured model is unavailable.", - hint: "Run `opencode models` and choose a currently available provider/model ID.", - }); - } - } - - if (canRunProbe && modelValidationPassed) { - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - const variant = asString(config.variant, "").trim(); - const probeModel = configuredModel; - - const args = ["run", "--format", "json"]; - args.push("--model", probeModel); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - - try { - const probe = await runChildProcess( - `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, - command, - args, - { + const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); + if (!configuredModel && !modelUnavailable) { + // No model configured – skip model requirement if no model-related checks exist + } else if (configuredModel && canRunProbe) { + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: configuredModel, + command, cwd, env: runtimeEnv, - timeoutSec: 60, - graceSec: 5, - stdin: "Respond with hello.", - onLog: async () => {}, - }, - ); + }); + checks.push({ + code: "opencode_model_configured", + level: "info", + message: `Configured model: ${configuredModel}`, + }); + modelValidationPassed = true; + } catch (err) { + checks.push({ + code: "opencode_model_invalid", + level: "error", + message: err instanceof Error ? err.message : "Configured model is unavailable.", + hint: "Run `opencode models` and choose a currently available provider/model ID.", + }); + } + } - const parsed = parseOpenCodeJsonl(probe.stdout); - const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); - const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + if (canRunProbe && modelValidationPassed) { + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + const variant = asString(config.variant, "").trim(); + const probeModel = configuredModel; - if (probe.timedOut) { - checks.push({ - code: "opencode_hello_probe_timed_out", - level: "warn", - message: "OpenCode hello probe timed out.", - hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", - }); - } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { - const summary = parsed.summary.trim(); - const hasHello = /\bhello\b/i.test(summary); - checks.push({ - code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", - level: hasHello ? "info" : "warn", - message: hasHello - ? "OpenCode hello probe succeeded." - : "OpenCode probe ran but did not return `hello` as expected.", - ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), - ...(hasHello - ? {} - : { - hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", - }), - }); - } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - ...(detail ? { detail } : {}), - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_auth_required", - level: "warn", - message: "OpenCode is installed, but provider authentication is not ready.", - ...(detail ? { detail } : {}), - hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", - }); - } else { + const args = ["run", "--format", "json"]; + args.push("--model", probeModel); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + + try { + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env: runtimeEnv, + timeoutSec: 60, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); + + const parsed = parseOpenCodeJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "opencode_hello_probe_timed_out", + level: "warn", + message: "OpenCode hello probe timed out.", + hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", + }); + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "OpenCode hello probe succeeded." + : "OpenCode probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", + }), + }); + } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + ...(detail ? { detail } : {}), + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_auth_required", + level: "warn", + message: "OpenCode is installed, but provider authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", + }); + } else { + checks.push({ + code: "opencode_hello_probe_failed", + level: "error", + message: "OpenCode hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `opencode run --format json` manually in this working directory to debug.", + }); + } + } catch (err) { checks.push({ code: "opencode_hello_probe_failed", level: "error", message: "OpenCode hello probe failed.", - ...(detail ? { detail } : {}), + detail: err instanceof Error ? err.message : String(err), hint: "Run `opencode run --format json` manually in this working directory to debug.", }); } - } catch (err) { - checks.push({ - code: "opencode_hello_probe_failed", - level: "error", - message: "OpenCode hello probe failed.", - detail: err instanceof Error ? err.message : String(err), - hint: "Run `opencode run --format json` manually in this working directory to debug.", - }); } + } finally { + await preparedRuntimeConfig.cleanup(); } return { diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 0d425cf1..fa941ed2 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -58,6 +58,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -const tempPaths: string[] = []; -const runningInstances: EmbeddedPostgresInstance[] = []; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; async function createTempDatabase(): Promise { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-client-")); - tempPaths.push(dataDir); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - runningInstances.push(instance); - - const adminUrl = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminUrl, "paperclip"); - return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + const db = await startEmbeddedPostgresTestDatabase("paperclip-db-client-"); + cleanups.push(db.cleanup); + return db.connectionString; } async function migrationHash(migrationFile: string): Promise { @@ -89,19 +30,19 @@ async function migrationHash(migrationFile: string): Promise { } afterEach(async () => { - while (runningInstances.length > 0) { - const instance = runningInstances.pop(); - if (!instance) continue; - await instance.stop(); - } - while (tempPaths.length > 0) { - const tempPath = tempPaths.pop(); - if (!tempPath) continue; - fs.rmSync(tempPath, { recursive: true, force: true }); + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + await cleanup?.(); } }); -describe("applyPendingMigrations", () => { +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("applyPendingMigrations", () => { it( "applies an inserted earlier migration without replaying later legacy migrations", async () => { diff --git a/packages/db/src/embedded-postgres-error.test.ts b/packages/db/src/embedded-postgres-error.test.ts new file mode 100644 index 00000000..dba1ad46 --- /dev/null +++ b/packages/db/src/embedded-postgres-error.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js"; + +describe("formatEmbeddedPostgresError", () => { + it("adds a shared-memory hint when initdb logs expose the real cause", () => { + const error = formatEmbeddedPostgresError("Postgres init script exited with code 1.", { + fallbackMessage: "Failed to initialize embedded PostgreSQL cluster", + recentLogs: [ + "running bootstrap script ...", + "FATAL: could not create shared memory segment: Cannot allocate memory", + "DETAIL: Failed system call was shmget(key=123, size=56, 03600).", + ], + }); + + expect(error.message).toContain("could not allocate shared memory"); + expect(error.message).toContain("kern.sysv.shm"); + expect(error.message).toContain("could not create shared memory segment"); + }); + + it("keeps only recent non-empty log lines in the collector", () => { + const buffer = createEmbeddedPostgresLogBuffer(2); + buffer.append("line one\n\n"); + buffer.append("line two"); + buffer.append("line three"); + + expect(buffer.getRecentLogs()).toEqual(["line two", "line three"]); + }); +}); diff --git a/packages/db/src/embedded-postgres-error.ts b/packages/db/src/embedded-postgres-error.ts new file mode 100644 index 00000000..9862a0f3 --- /dev/null +++ b/packages/db/src/embedded-postgres-error.ts @@ -0,0 +1,89 @@ +const DEFAULT_RECENT_LOG_LIMIT = 40; +const RECENT_LOG_SUMMARY_LINES = 8; + +function toError(error: unknown, fallbackMessage: string): Error { + if (error instanceof Error) return error; + if (error === undefined) return new Error(fallbackMessage); + if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`); + + try { + return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`); + } catch { + return new Error(`${fallbackMessage}: ${String(error)}`); + } +} + +function summarizeRecentLogs(recentLogs: string[]): string | null { + if (recentLogs.length === 0) return null; + return recentLogs + .slice(-RECENT_LOG_SUMMARY_LINES) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join(" | "); +} + +function detectEmbeddedPostgresHint(recentLogs: string[]): string | null { + const haystack = recentLogs.join("\n").toLowerCase(); + if (!haystack.includes("could not create shared memory segment")) { + return null; + } + + return ( + "Embedded PostgreSQL bootstrap could not allocate shared memory. " + + "On macOS, this usually means the host's kern.sysv.shm* limits are too low for another local PostgreSQL cluster. " + + "Stop other local PostgreSQL servers or raise the shared-memory sysctls, then retry." + ); +} + +export function createEmbeddedPostgresLogBuffer(limit = DEFAULT_RECENT_LOG_LIMIT): { + append(message: unknown): void; + getRecentLogs(): string[]; +} { + const recentLogs: string[] = []; + + return { + append(message: unknown) { + const text = + typeof message === "string" + ? message + : message instanceof Error + ? message.message + : String(message ?? ""); + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + recentLogs.push(line); + if (recentLogs.length > limit) { + recentLogs.splice(0, recentLogs.length - limit); + } + } + }, + getRecentLogs() { + return [...recentLogs]; + }, + }; +} + +export function formatEmbeddedPostgresError( + error: unknown, + input: { + fallbackMessage: string; + recentLogs?: string[]; + }, +): Error { + const baseError = toError(error, input.fallbackMessage); + const recentLogs = input.recentLogs ?? []; + const parts = [baseError.message]; + const hint = detectEmbeddedPostgresHint(recentLogs); + const recentSummary = summarizeRecentLogs(recentLogs); + + if (hint) { + parts.push(hint); + } + if (recentSummary) { + parts.push(`Recent embedded Postgres logs: ${recentSummary}`); + } + + return new Error(parts.join(" ")); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5c32ab13..6c45acbc 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,6 +11,12 @@ export { type MigrationBootstrapResult, type Db, } from "./client.js"; +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "./test-embedded-postgres.js"; export { runDatabaseBackup, runDatabaseRestore, @@ -19,4 +25,8 @@ export { type RunDatabaseBackupResult, type RunDatabaseRestoreOptions, } from "./backup-lib.js"; +export { + createEmbeddedPostgresLogBuffer, + formatEmbeddedPostgresError, +} from "./embedded-postgres-error.js"; export * from "./schema/index.js"; diff --git a/packages/db/src/migration-runtime.ts b/packages/db/src/migration-runtime.ts index 921de612..5aa2b6a2 100644 --- a/packages/db/src/migration-runtime.ts +++ b/packages/db/src/migration-runtime.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, rmSync } from "node:fs"; import { createServer } from "node:net"; import path from "node:path"; import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js"; +import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js"; import { resolveDatabaseTarget } from "./runtime-config.js"; type EmbeddedPostgresInstance = { @@ -27,18 +28,6 @@ export type MigrationConnection = { stop: () => Promise; }; -function toError(error: unknown, fallbackMessage: string): Error { - if (error instanceof Error) return error; - if (error === undefined) return new Error(fallbackMessage); - if (typeof error === "string") return new Error(`${fallbackMessage}: ${error}`); - - try { - return new Error(`${fallbackMessage}: ${JSON.stringify(error)}`); - } catch { - return new Error(`${fallbackMessage}: ${String(error)}`); - } -} - function readRunningPostmasterPid(postmasterPidFile: string): number | null { if (!existsSync(postmasterPidFile)) return null; try { @@ -109,6 +98,7 @@ async function ensureEmbeddedPostgresConnection( const runningPid = readRunningPostmasterPid(postmasterPidFile); const runningPort = readPidFilePort(postmasterPidFile); const preferredAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${preferredPort}/postgres`; + const logBuffer = createEmbeddedPostgresLogBuffer(); if (!runningPid && existsSync(pgVersionFile)) { try { @@ -151,18 +141,19 @@ async function ensureEmbeddedPostgresConnection( port: selectedPort, persistent: true, initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, + onLog: logBuffer.append, + onError: logBuffer.append, }); if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { try { await instance.initialise(); } catch (error) { - throw toError( - error, - `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`, - ); + throw formatEmbeddedPostgresError(error, { + fallbackMessage: + `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${selectedPort}`, + recentLogs: logBuffer.getRecentLogs(), + }); } } if (existsSync(postmasterPidFile)) { @@ -171,7 +162,10 @@ async function ensureEmbeddedPostgresConnection( try { await instance.start(); } catch (error) { - throw toError(error, `Failed to start embedded PostgreSQL on port ${selectedPort}`); + throw formatEmbeddedPostgresError(error, { + fallbackMessage: `Failed to start embedded PostgreSQL on port ${selectedPort}`, + recentLogs: logBuffer.getRecentLogs(), + }); } const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${selectedPort}/postgres`; diff --git a/packages/db/src/migrations/0045_workable_shockwave.sql b/packages/db/src/migrations/0045_workable_shockwave.sql new file mode 100644 index 00000000..b38398fa --- /dev/null +++ b/packages/db/src/migrations/0045_workable_shockwave.sql @@ -0,0 +1,17 @@ +CREATE TABLE "issue_inbox_archives" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "issue_id" uuid NOT NULL, + "user_id" text NOT NULL, + "archived_at" timestamp with time zone DEFAULT now() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DROP INDEX "board_api_keys_key_hash_idx";--> statement-breakpoint +ALTER TABLE "issue_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_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_inbox_archives" ADD CONSTRAINT "issue_inbox_archives_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "issue_inbox_archives_company_issue_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id");--> statement-breakpoint +CREATE INDEX "issue_inbox_archives_company_user_idx" ON "issue_inbox_archives" USING btree ("company_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "issue_inbox_archives_company_issue_user_idx" ON "issue_inbox_archives" USING btree ("company_id","issue_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "board_api_keys_key_hash_idx" ON "board_api_keys" USING btree ("key_hash"); \ No newline at end of file diff --git a/packages/db/src/migrations/0045_breezy_dexter_bennett.sql b/packages/db/src/migrations/0046_smooth_sentinels.sql similarity index 86% rename from packages/db/src/migrations/0045_breezy_dexter_bennett.sql rename to packages/db/src/migrations/0046_smooth_sentinels.sql index a98f5de9..858124c3 100644 --- a/packages/db/src/migrations/0045_breezy_dexter_bennett.sql +++ b/packages/db/src/migrations/0046_smooth_sentinels.sql @@ -1,5 +1,6 @@ ALTER TABLE "document_revisions" ADD COLUMN "title" text;--> statement-breakpoint -ALTER TABLE "document_revisions" ADD COLUMN "format" text DEFAULT 'markdown' NOT NULL;--> statement-breakpoint +ALTER TABLE "document_revisions" ADD COLUMN "format" text DEFAULT 'markdown' NOT NULL; +--> statement-breakpoint UPDATE "document_revisions" AS "dr" SET "title" = "d"."title", diff --git a/packages/db/src/migrations/meta/0045_snapshot.json b/packages/db/src/migrations/meta/0045_snapshot.json index 96e7692d..9a9fd3d4 100644 --- a/packages/db/src/migrations/meta/0045_snapshot.json +++ b/packages/db/src/migrations/meta/0045_snapshot.json @@ -1,5 +1,5 @@ { - "id": "185d59be-1832-4c34-95ee-131b7553a67a", + "id": "869b0102-2cb8-48e8-a6d8-cab88f0fa7a8", "prevId": "a7a034eb-984f-4884-b6e1-87c453404b4e", "version": "7", "dialect": "postgresql", @@ -4087,19 +4087,6 @@ "primaryKey": false, "notNull": true }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "format": { - "name": "format", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'markdown'" - }, "body": { "name": "body", "type": "text", @@ -6781,6 +6768,162 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.issue_inbox_archives": { + "name": "issue_inbox_archives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_inbox_archives_company_issue_idx": { + "name": "issue_inbox_archives_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_user_idx": { + "name": "issue_inbox_archives_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_issue_user_idx": { + "name": "issue_inbox_archives_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_inbox_archives_company_id_companies_id_fk": { + "name": "issue_inbox_archives_company_id_companies_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_inbox_archives_issue_id_issues_id_fk": { + "name": "issue_inbox_archives_issue_id_issues_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.issue_labels": { "name": "issue_labels", "schema": "", diff --git a/packages/db/src/migrations/meta/0046_snapshot.json b/packages/db/src/migrations/meta/0046_snapshot.json new file mode 100644 index 00000000..f8c1a504 --- /dev/null +++ b/packages/db/src/migrations/meta/0046_snapshot.json @@ -0,0 +1,11870 @@ +{ + "id": "4ae31a44-1b98-4437-88ef-92b76d014107", + "prevId": "869b0102-2cb8-48e8-a6d8-cab88f0fa7a8", + "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 + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agents_company_status_idx": { + "name": "agents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agents_company_reports_to_idx": { + "name": "agents_company_reports_to_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reports_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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.board_api_keys": { + "name": "board_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "board_api_keys_key_hash_idx": { + "name": "board_api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "board_api_keys_user_idx": { + "name": "board_api_keys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "board_api_keys_user_id_user_id_fk": { + "name": "board_api_keys_user_id_user_id_fk", + "tableFrom": "board_api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_incidents": { + "name": "budget_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "policy_id": { + "name": "policy_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "threshold_type": { + "name": "threshold_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_limit": { + "name": "amount_limit", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_observed": { + "name": "amount_observed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_incidents_company_status_idx": { + "name": "budget_incidents_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_company_scope_idx": { + "name": "budget_incidents_company_scope_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_incidents_policy_window_threshold_idx": { + "name": "budget_incidents_policy_window_threshold_idx", + "columns": [ + { + "expression": "policy_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "threshold_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"budget_incidents\".\"status\" <> 'dismissed'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_incidents_company_id_companies_id_fk": { + "name": "budget_incidents_company_id_companies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_policy_id_budget_policies_id_fk": { + "name": "budget_incidents_policy_id_budget_policies_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "budget_policies", + "columnsFrom": [ + "policy_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budget_incidents_approval_id_approvals_id_fk": { + "name": "budget_incidents_approval_id_approvals_id_fk", + "tableFrom": "budget_incidents", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.budget_policies": { + "name": "budget_policies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'billed_cents'" + }, + "window_kind": { + "name": "window_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "warn_percent": { + "name": "warn_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 80 + }, + "hard_stop_enabled": { + "name": "hard_stop_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notify_enabled": { + "name": "notify_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "budget_policies_company_scope_active_idx": { + "name": "budget_policies_company_scope_active_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_window_idx": { + "name": "budget_policies_company_window_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_policies_company_scope_metric_unique_idx": { + "name": "budget_policies_company_scope_metric_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budget_policies_company_id_companies_id_fk": { + "name": "budget_policies_company_id_companies_id_fk", + "tableFrom": "budget_policies", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_auth_challenges": { + "name": "cli_auth_challenges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_access": { + "name": "requested_access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'board'" + }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pending_key_hash": { + "name": "pending_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pending_key_name": { + "name": "pending_key_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "board_api_key_id": { + "name": "board_api_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cli_auth_challenges_secret_hash_idx": { + "name": "cli_auth_challenges_secret_hash_idx", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_approved_by_idx": { + "name": "cli_auth_challenges_approved_by_idx", + "columns": [ + { + "expression": "approved_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cli_auth_challenges_requested_company_idx": { + "name": "cli_auth_challenges_requested_company_idx", + "columns": [ + { + "expression": "requested_company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_auth_challenges_requested_company_id_companies_id_fk": { + "name": "cli_auth_challenges_requested_company_id_companies_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "companies", + "columnsFrom": [ + "requested_company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_approved_by_user_id_user_id_fk": { + "name": "cli_auth_challenges_approved_by_user_id_user_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "user", + "columnsFrom": [ + "approved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk": { + "name": "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk", + "tableFrom": "cli_auth_challenges", + "tableTo": "board_api_keys", + "columnsFrom": [ + "board_api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.companies": { + "name": "companies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "issue_prefix": { + "name": "issue_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'PAP'" + }, + "issue_counter": { + "name": "issue_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "budget_monthly_cents": { + "name": "budget_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "spent_monthly_cents": { + "name": "spent_monthly_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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_logos": { + "name": "company_logos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_logos_company_uq": { + "name": "company_logos_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_logos_asset_uq": { + "name": "company_logos_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_logos_company_id_companies_id_fk": { + "name": "company_logos_company_id_companies_id_fk", + "tableFrom": "company_logos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_logos_asset_id_assets_id_fk": { + "name": "company_logos_asset_id_assets_id_fk", + "tableFrom": "company_logos", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skills": { + "name": "company_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "source_locator": { + "name": "source_locator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trust_level": { + "name": "trust_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown_only'" + }, + "compatibility": { + "name": "compatibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'compatible'" + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skills_company_key_idx": { + "name": "company_skills_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_name_idx": { + "name": "company_skills_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skills_company_id_companies_id_fk": { + "name": "company_skills_company_id_companies_id_fk", + "tableFrom": "company_skills", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_provider_occurred_idx": { + "name": "cost_events_company_provider_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_biller_occurred_idx": { + "name": "cost_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_heartbeat_run_idx": { + "name": "cost_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_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.execution_workspaces": { + "name": "execution_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy_type": { + "name": "strategy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_fs'" + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "derived_from_execution_workspace_id": { + "name": "derived_from_execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_eligible_at": { + "name": "cleanup_eligible_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_workspaces_company_project_status_idx": { + "name": "execution_workspaces_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_project_workspace_status_idx": { + "name": "execution_workspaces_company_project_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_source_issue_idx": { + "name": "execution_workspaces_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_last_used_idx": { + "name": "execution_workspaces_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_branch_idx": { + "name": "execution_workspaces_company_branch_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspaces_company_id_companies_id_fk": { + "name": "execution_workspaces_company_id_companies_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "execution_workspaces_project_id_projects_id_fk": { + "name": "execution_workspaces_project_id_projects_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { + "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_source_issue_id_issues_id_fk": { + "name": "execution_workspaces_source_issue_id_issues_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "derived_from_execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_events": { + "name": "finance_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_kind": { + "name": "event_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'debit'" + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_adapter_type": { + "name": "execution_adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing_tier": { + "name": "pricing_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_invoice_id": { + "name": "external_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "finance_events_company_occurred_idx": { + "name": "finance_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_biller_occurred_idx": { + "name": "finance_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_kind_occurred_idx": { + "name": "finance_events_company_kind_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_direction_occurred_idx": { + "name": "finance_events_company_direction_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_heartbeat_run_idx": { + "name": "finance_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_cost_event_idx": { + "name": "finance_events_company_cost_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_events_company_id_companies_id_fk": { + "name": "finance_events_company_id_companies_id_fk", + "tableFrom": "finance_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_agent_id_agents_id_fk": { + "name": "finance_events_agent_id_agents_id_fk", + "tableFrom": "finance_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_issue_id_issues_id_fk": { + "name": "finance_events_issue_id_issues_id_fk", + "tableFrom": "finance_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_project_id_projects_id_fk": { + "name": "finance_events_project_id_projects_id_fk", + "tableFrom": "finance_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_goal_id_goals_id_fk": { + "name": "finance_events_goal_id_goals_id_fk", + "tableFrom": "finance_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_cost_event_id_cost_events_id_fk": { + "name": "finance_events_cost_event_id_cost_events_id_fk", + "tableFrom": "finance_events", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retry_of_run_id": { + "name": "retry_of_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "process_loss_retry_count": { + "name": "process_loss_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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" + }, + "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_runs_retry_of_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_runs", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "retry_of_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "experimental": { + "name": "experimental", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_settings_singleton_key_idx": { + "name": "instance_settings_singleton_key_idx", + "columns": [ + { + "expression": "singleton_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_inbox_archives": { + "name": "issue_inbox_archives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_inbox_archives_company_issue_idx": { + "name": "issue_inbox_archives_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_user_idx": { + "name": "issue_inbox_archives_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_issue_user_idx": { + "name": "issue_inbox_archives_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_inbox_archives_company_id_companies_id_fk": { + "name": "issue_inbox_archives_company_id_companies_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_inbox_archives_issue_id_issues_id_fk": { + "name": "issue_inbox_archives_issue_id_issues_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_work_products": { + "name": "issue_work_products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "runtime_service_id": { + "name": "runtime_service_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_state": { + "name": "review_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_work_products_company_issue_type_idx": { + "name": "issue_work_products_company_issue_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_execution_workspace_type_idx": { + "name": "issue_work_products_company_execution_workspace_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_provider_external_id_idx": { + "name": "issue_work_products_company_provider_external_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_work_products_company_updated_idx": { + "name": "issue_work_products_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_work_products_company_id_companies_id_fk": { + "name": "issue_work_products_company_id_companies_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_work_products_project_id_projects_id_fk": { + "name": "issue_work_products_project_id_projects_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_issue_id_issues_id_fk": { + "name": "issue_work_products_issue_id_issues_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_work_products_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issue_work_products_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk": { + "name": "issue_work_products_runtime_service_id_workspace_runtime_services_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "workspace_runtime_services", + "columnsFrom": [ + "runtime_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issue_work_products_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_work_products_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_work_products", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issues": { + "name": "issues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_user_id": { + "name": "assignee_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_run_id": { + "name": "checkout_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_run_id": { + "name": "execution_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_agent_name_key": { + "name": "execution_agent_name_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_locked_at": { + "name": "execution_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_preference": { + "name": "execution_workspace_preference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_settings": { + "name": "execution_workspace_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "hidden_at": { + "name": "hidden_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issues_company_status_idx": { + "name": "issues_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_status_idx": { + "name": "issues_company_assignee_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_assignee_user_status_idx": { + "name": "issues_company_assignee_user_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_parent_idx": { + "name": "issues_company_parent_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_idx": { + "name": "issues_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_origin_idx": { + "name": "issues_company_origin_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_project_workspace_idx": { + "name": "issues_company_project_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_company_execution_workspace_idx": { + "name": "issues_company_execution_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_identifier_idx": { + "name": "issues_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issues_open_routine_execution_uq": { + "name": "issues_open_routine_execution_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"issues\".\"origin_kind\" = 'routine_execution'\n and \"issues\".\"origin_id\" is not null\n and \"issues\".\"hidden_at\" is null\n and \"issues\".\"execution_run_id\" is not null\n and \"issues\".\"status\" in ('backlog', 'todo', 'in_progress', 'in_review', 'blocked')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issues_company_id_companies_id_fk": { + "name": "issues_company_id_companies_id_fk", + "tableFrom": "issues", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_id_projects_id_fk": { + "name": "issues_project_id_projects_id_fk", + "tableFrom": "issues", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_project_workspace_id_project_workspaces_id_fk": { + "name": "issues_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_goal_id_goals_id_fk": { + "name": "issues_goal_id_goals_id_fk", + "tableFrom": "issues", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_parent_id_issues_id_fk": { + "name": "issues_parent_id_issues_id_fk", + "tableFrom": "issues", + "tableTo": "issues", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_assignee_agent_id_agents_id_fk": { + "name": "issues_assignee_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_checkout_run_id_heartbeat_runs_id_fk": { + "name": "issues_checkout_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "checkout_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_execution_run_id_heartbeat_runs_id_fk": { + "name": "issues_execution_run_id_heartbeat_runs_id_fk", + "tableFrom": "issues", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "execution_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "issues_created_by_agent_id_agents_id_fk": { + "name": "issues_created_by_agent_id_agents_id_fk", + "tableFrom": "issues", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issues_execution_workspace_id_execution_workspaces_id_fk": { + "name": "issues_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "issues", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.join_requests": { + "name": "join_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_id": { + "name": "invite_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_type": { + "name": "request_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_approval'" + }, + "request_ip": { + "name": "request_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requesting_user_id": { + "name": "requesting_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_email_snapshot": { + "name": "request_email_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "adapter_type": { + "name": "adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_defaults_payload": { + "name": "agent_defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_secret_expires_at": { + "name": "claim_secret_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_secret_consumed_at": { + "name": "claim_secret_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_agent_id": { + "name": "created_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by_user_id": { + "name": "rejected_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "join_requests_invite_unique_idx": { + "name": "join_requests_invite_unique_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "join_requests_company_status_type_created_idx": { + "name": "join_requests_company_status_type_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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 + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_ref": { + "name": "default_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "setup_command": { + "name": "setup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cleanup_command": { + "name": "cleanup_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_provider": { + "name": "remote_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "remote_workspace_ref": { + "name": "remote_workspace_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_workspace_key": { + "name": "shared_workspace_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_workspaces_company_project_idx": { + "name": "project_workspaces_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_primary_idx": { + "name": "project_workspaces_project_primary_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_primary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_source_type_idx": { + "name": "project_workspaces_project_source_type_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_company_shared_key_idx": { + "name": "project_workspaces_company_shared_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shared_workspace_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_workspaces_project_remote_ref_idx": { + "name": "project_workspaces_project_remote_ref_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_workspace_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_workspaces_company_id_companies_id_fk": { + "name": "project_workspaces_company_id_companies_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "project_workspaces_project_id_projects_id_fk": { + "name": "project_workspaces_project_id_projects_id_fk", + "tableFrom": "project_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.projects": { + "name": "projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'backlog'" + }, + "lead_agent_id": { + "name": "lead_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_date": { + "name": "target_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "triggered_at": { + "name": "triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_payload": { + "name": "trigger_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "linked_issue_id": { + "name": "linked_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "coalesced_into_run_id": { + "name": "coalesced_into_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_runs_company_routine_idx": { + "name": "routine_runs_company_routine_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idx": { + "name": "routine_runs_trigger_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_linked_issue_idx": { + "name": "routine_runs_linked_issue_idx", + "columns": [ + { + "expression": "linked_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_runs_trigger_idempotency_idx": { + "name": "routine_runs_trigger_idempotency_idx", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_runs_company_id_companies_id_fk": { + "name": "routine_runs_company_id_companies_id_fk", + "tableFrom": "routine_runs", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_routine_id_routines_id_fk": { + "name": "routine_runs_routine_id_routines_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_runs_trigger_id_routine_triggers_id_fk": { + "name": "routine_runs_trigger_id_routine_triggers_id_fk", + "tableFrom": "routine_runs", + "tableTo": "routine_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_runs_linked_issue_id_issues_id_fk": { + "name": "routine_runs_linked_issue_id_issues_id_fk", + "tableFrom": "routine_runs", + "tableTo": "issues", + "columnsFrom": [ + "linked_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_triggers": { + "name": "routine_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "signing_mode": { + "name": "signing_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replay_window_sec": { + "name": "replay_window_sec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_rotated_at": { + "name": "last_rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_result": { + "name": "last_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_triggers_company_routine_idx": { + "name": "routine_triggers_company_routine_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "routine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_company_kind_idx": { + "name": "routine_triggers_company_kind_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_next_run_idx": { + "name": "routine_triggers_next_run_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_public_id_idx": { + "name": "routine_triggers_public_id_idx", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routine_triggers_public_id_uq": { + "name": "routine_triggers_public_id_uq", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_triggers_company_id_companies_id_fk": { + "name": "routine_triggers_company_id_companies_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_triggers_routine_id_routines_id_fk": { + "name": "routine_triggers_routine_id_routines_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "routines", + "columnsFrom": [ + "routine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_triggers_secret_id_company_secrets_id_fk": { + "name": "routine_triggers_secret_id_company_secrets_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_triggers_created_by_agent_id_agents_id_fk": { + "name": "routine_triggers_created_by_agent_id_agents_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routine_triggers_updated_by_agent_id_agents_id_fk": { + "name": "routine_triggers_updated_by_agent_id_agents_id_fk", + "tableFrom": "routine_triggers", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routines": { + "name": "routines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_issue_id": { + "name": "parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'coalesce_if_active'" + }, + "catch_up_policy": { + "name": "catch_up_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip_missed'" + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_company_status_idx": { + "name": "routines_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_assignee_idx": { + "name": "routines_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_project_idx": { + "name": "routines_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_company_id_companies_id_fk": { + "name": "routines_company_id_companies_id_fk", + "tableFrom": "routines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_project_id_projects_id_fk": { + "name": "routines_project_id_projects_id_fk", + "tableFrom": "routines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_goal_id_goals_id_fk": { + "name": "routines_goal_id_goals_id_fk", + "tableFrom": "routines", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_parent_issue_id_issues_id_fk": { + "name": "routines_parent_issue_id_issues_id_fk", + "tableFrom": "routines", + "tableTo": "issues", + "columnsFrom": [ + "parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_assignee_agent_id_agents_id_fk": { + "name": "routines_assignee_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routines_created_by_agent_id_agents_id_fk": { + "name": "routines_created_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_updated_by_agent_id_agents_id_fk": { + "name": "routines_updated_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_operations": { + "name": "workspace_operations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_operations_company_run_started_idx": { + "name": "workspace_operations_company_run_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_operations_company_workspace_started_idx": { + "name": "workspace_operations_company_workspace_started_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_operations_company_id_companies_id_fk": { + "name": "workspace_operations_company_id_companies_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_operations_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_operations_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_operations", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_runtime_services": { + "name": "workspace_runtime_services", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "service_name": { + "name": "service_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reuse_key": { + "name": "reuse_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "started_by_run_id": { + "name": "started_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stop_policy": { + "name": "stop_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "health_status": { + "name": "health_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_runtime_services_company_workspace_status_idx": { + "name": "workspace_runtime_services_company_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_execution_workspace_status_idx": { + "name": "workspace_runtime_services_company_execution_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_project_status_idx": { + "name": "workspace_runtime_services_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_run_idx": { + "name": "workspace_runtime_services_run_idx", + "columns": [ + { + "expression": "started_by_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_runtime_services_company_updated_idx": { + "name": "workspace_runtime_services_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_runtime_services_company_id_companies_id_fk": { + "name": "workspace_runtime_services_company_id_companies_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_id_projects_id_fk": { + "name": "workspace_runtime_services_project_id_projects_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk": { + "name": "workspace_runtime_services_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk": { + "name": "workspace_runtime_services_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_issue_id_issues_id_fk": { + "name": "workspace_runtime_services_issue_id_issues_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_owner_agent_id_agents_id_fk": { + "name": "workspace_runtime_services_owner_agent_id_agents_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk": { + "name": "workspace_runtime_services_started_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "workspace_runtime_services", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "started_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "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 47c35bac..adb00a47 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -320,8 +320,15 @@ { "idx": 45, "version": "7", - "when": 1774531054196, - "tag": "0045_breezy_dexter_bennett", + "when": 1774530504348, + "tag": "0045_workable_shockwave", + "breakpoints": true + }, + { + "idx": 46, + "version": "7", + "when": 1774960197878, + "tag": "0046_smooth_sentinels", "breakpoints": true } ] diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index a411ddcb..fc387334 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -31,6 +31,7 @@ export { labels } from "./labels.js"; export { issueLabels } from "./issue_labels.js"; export { issueApprovals } from "./issue_approvals.js"; export { issueComments } from "./issue_comments.js"; +export { issueInboxArchives } from "./issue_inbox_archives.js"; export { issueReadStates } from "./issue_read_states.js"; export { assets } from "./assets.js"; export { issueAttachments } from "./issue_attachments.js"; diff --git a/packages/db/src/schema/issue_inbox_archives.ts b/packages/db/src/schema/issue_inbox_archives.ts new file mode 100644 index 00000000..73152f13 --- /dev/null +++ b/packages/db/src/schema/issue_inbox_archives.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; +import { issues } from "./issues.js"; + +export const issueInboxArchives = pgTable( + "issue_inbox_archives", + { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id), + issueId: uuid("issue_id").notNull().references(() => issues.id), + userId: text("user_id").notNull(), + archivedAt: timestamp("archived_at", { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => ({ + companyIssueIdx: index("issue_inbox_archives_company_issue_idx").on(table.companyId, table.issueId), + companyUserIdx: index("issue_inbox_archives_company_user_idx").on(table.companyId, table.userId), + companyIssueUserUnique: uniqueIndex("issue_inbox_archives_company_issue_user_idx").on( + table.companyId, + table.issueId, + table.userId, + ), + }), +); diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts new file mode 100644 index 00000000..04fa642d --- /dev/null +++ b/packages/db/src/test-embedded-postgres.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + + return { + connectionString, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/packages/plugins/sdk/src/protocol.ts b/packages/plugins/sdk/src/protocol.ts index 8a99bcba..a26bf5dc 100644 --- a/packages/plugins/sdk/src/protocol.ts +++ b/packages/plugins/sdk/src/protocol.ts @@ -579,6 +579,7 @@ export interface WorkerToHostMethods { projectId?: string; goalId?: string; parentId?: string; + inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; priority?: string; diff --git a/packages/plugins/sdk/src/types.ts b/packages/plugins/sdk/src/types.ts index 06046983..51824651 100644 --- a/packages/plugins/sdk/src/types.ts +++ b/packages/plugins/sdk/src/types.ts @@ -872,6 +872,7 @@ export interface PluginIssuesClient { projectId?: string; goalId?: string; parentId?: string; + inheritExecutionWorkspaceFromIssueId?: string; title: string; description?: string; priority?: Issue["priority"]; diff --git a/packages/plugins/sdk/src/worker-rpc-host.ts b/packages/plugins/sdk/src/worker-rpc-host.ts index df387490..20ca02fc 100644 --- a/packages/plugins/sdk/src/worker-rpc-host.ts +++ b/packages/plugins/sdk/src/worker-rpc-host.ts @@ -590,6 +590,7 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost projectId: input.projectId, goalId: input.goalId, parentId: input.parentId, + inheritExecutionWorkspaceFromIssueId: input.inheritExecutionWorkspaceFromIssueId, title: input.title, description: input.description, priority: input.priority, diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 0c5aa424..6ace12a4 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -119,6 +119,16 @@ export const ISSUE_STATUSES = [ ] as const; export type IssueStatus = (typeof ISSUE_STATUSES)[number]; +export const INBOX_MINE_ISSUE_STATUSES = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", +] as const; +export const INBOX_MINE_ISSUE_STATUS_FILTER = INBOX_MINE_ISSUE_STATUSES.join(","); + export const ISSUE_PRIORITIES = ["critical", "high", "medium", "low"] as const; export type IssuePriority = (typeof ISSUE_PRIORITIES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dfbfbde7..da0df684 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,8 @@ export { AGENT_ROLE_LABELS, AGENT_ICON_NAMES, ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, ISSUE_PRIORITIES, ISSUE_ORIGIN_KINDS, GOAL_LEVELS, @@ -186,10 +188,19 @@ export type { ProjectGoalRef, ProjectWorkspace, ExecutionWorkspace, + ExecutionWorkspaceConfig, + ExecutionWorkspaceCloseAction, + ExecutionWorkspaceCloseActionKind, + ExecutionWorkspaceCloseGitReadiness, + ExecutionWorkspaceCloseLinkedIssue, + ExecutionWorkspaceCloseReadiness, + ExecutionWorkspaceCloseReadinessState, + ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus, + WorkspaceRuntimeDesiredState, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, ExecutionWorkspaceProviderType, @@ -344,6 +355,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -356,6 +368,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, @@ -384,6 +397,12 @@ export { issueWorkProductReviewStateSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, + executionWorkspaceCloseActionKindSchema, + executionWorkspaceCloseActionSchema, + executionWorkspaceCloseGitReadinessSchema, + executionWorkspaceCloseLinkedIssueSchema, + executionWorkspaceCloseReadinessSchema, + executionWorkspaceCloseReadinessStateSchema, issueDocumentFormatSchema, issueDocumentKeySchema, upsertIssueDocumentSchema, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index dd615c4c..dfe4b9d5 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -50,7 +50,16 @@ export type { AssetImage } from "./asset.js"; export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectWorkspace } from "./project.js"; export type { ExecutionWorkspace, + ExecutionWorkspaceConfig, + ExecutionWorkspaceCloseAction, + ExecutionWorkspaceCloseActionKind, + ExecutionWorkspaceCloseGitReadiness, + ExecutionWorkspaceCloseLinkedIssue, + ExecutionWorkspaceCloseReadiness, + ExecutionWorkspaceCloseReadinessState, + ProjectWorkspaceRuntimeConfig, WorkspaceRuntimeService, + WorkspaceRuntimeDesiredState, ExecutionWorkspaceStrategyType, ExecutionWorkspaceMode, ExecutionWorkspaceProviderType, diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index ad977a63..d843b425 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -1,5 +1,9 @@ import type { PauseReason, ProjectStatus } from "../constants.js"; -import type { ProjectExecutionWorkspacePolicy, WorkspaceRuntimeService } from "./workspace-runtime.js"; +import type { + ProjectExecutionWorkspacePolicy, + ProjectWorkspaceRuntimeConfig, + WorkspaceRuntimeService, +} from "./workspace-runtime.js"; export type ProjectWorkspaceSourceType = "local_path" | "git_repo" | "remote_managed" | "non_git_path"; export type ProjectWorkspaceVisibility = "default" | "advanced"; @@ -26,6 +30,7 @@ export interface ProjectWorkspace { remoteWorkspaceRef: string | null; sharedWorkspaceKey: string | null; metadata: Record | null; + runtimeConfig: ProjectWorkspaceRuntimeConfig | null; isPrimary: boolean; runtimeServices?: WorkspaceRuntimeService[]; createdAt: Date; diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index 47ed9494..2b2c4e2d 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -31,6 +31,22 @@ export type ExecutionWorkspaceStatus = | "archived" | "cleanup_failed"; +export type ExecutionWorkspaceCloseReadinessState = + | "ready" + | "ready_with_warnings" + | "blocked"; + +export type ExecutionWorkspaceCloseActionKind = + | "archive_record" + | "stop_runtime_services" + | "cleanup_command" + | "teardown_command" + | "git_worktree_remove" + | "git_branch_delete" + | "remove_local_directory"; + +export type WorkspaceRuntimeDesiredState = "running" | "stopped"; + export interface ExecutionWorkspaceStrategy { type: ExecutionWorkspaceStrategyType; baseRef?: string | null; @@ -40,6 +56,63 @@ export interface ExecutionWorkspaceStrategy { teardownCommand?: string | null; } +export interface ExecutionWorkspaceConfig { + provisionCommand: string | null; + teardownCommand: string | null; + cleanupCommand: string | null; + workspaceRuntime: Record | null; + desiredState: WorkspaceRuntimeDesiredState | null; +} + +export interface ProjectWorkspaceRuntimeConfig { + workspaceRuntime: Record | null; + desiredState: WorkspaceRuntimeDesiredState | null; +} + +export interface ExecutionWorkspaceCloseAction { + kind: ExecutionWorkspaceCloseActionKind; + label: string; + description: string; + command: string | null; +} + +export interface ExecutionWorkspaceCloseLinkedIssue { + id: string; + identifier: string | null; + title: string; + status: string; + isTerminal: boolean; +} + +export interface ExecutionWorkspaceCloseGitReadiness { + repoRoot: string | null; + workspacePath: string | null; + branchName: string | null; + baseRef: string | null; + hasDirtyTrackedFiles: boolean; + hasUntrackedFiles: boolean; + dirtyEntryCount: number; + untrackedEntryCount: number; + aheadCount: number | null; + behindCount: number | null; + isMergedIntoBase: boolean | null; + createdByRuntime: boolean; +} + +export interface ExecutionWorkspaceCloseReadiness { + workspaceId: string; + state: ExecutionWorkspaceCloseReadinessState; + blockingReasons: string[]; + warnings: string[]; + linkedIssues: ExecutionWorkspaceCloseLinkedIssue[]; + plannedActions: ExecutionWorkspaceCloseAction[]; + isDestructiveCloseAllowed: boolean; + isSharedWorkspace: boolean; + isProjectPrimaryWorkspace: boolean; + git: ExecutionWorkspaceCloseGitReadiness | null; + runtimeServices: WorkspaceRuntimeService[]; +} + export interface ProjectExecutionWorkspacePolicy { enabled: boolean; defaultMode?: ProjectExecutionWorkspaceDefaultMode; @@ -81,7 +154,9 @@ export interface ExecutionWorkspace { closedAt: Date | null; cleanupEligibleAt: Date | null; cleanupReason: string | null; + config: ExecutionWorkspaceConfig | null; metadata: Record | null; + runtimeServices?: WorkspaceRuntimeService[]; createdAt: Date; updatedAt: Date; } diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 8c29150b..288ae683 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -4,6 +4,7 @@ import { AGENT_ICON_NAMES, AGENT_ROLES, AGENT_STATUSES, + INBOX_MINE_ISSUE_STATUS_FILTER, } from "../constants.js"; import { envConfigSchema } from "./secret.js"; @@ -93,6 +94,13 @@ export const createAgentKeySchema = z.object({ export type CreateAgentKey = z.infer; +export const agentMineInboxQuerySchema = z.object({ + userId: z.string().trim().min(1), + status: z.string().trim().min(1).optional().default(INBOX_MINE_ISSUE_STATUS_FILTER), +}); + +export type AgentMineInboxQuery = z.infer; + export const wakeAgentSchema = z.object({ source: z.enum(["timer", "assignment", "on_demand", "automation"]).optional().default("on_demand"), triggerDetail: z.enum(["manual", "ping", "callback", "system"]).optional(), diff --git a/packages/shared/src/validators/execution-workspace.ts b/packages/shared/src/validators/execution-workspace.ts index 53a74036..9914d74e 100644 --- a/packages/shared/src/validators/execution-workspace.ts +++ b/packages/shared/src/validators/execution-workspace.ts @@ -8,10 +8,115 @@ export const executionWorkspaceStatusSchema = z.enum([ "cleanup_failed", ]); +export const executionWorkspaceConfigSchema = z.object({ + provisionCommand: z.string().optional().nullable(), + teardownCommand: z.string().optional().nullable(), + cleanupCommand: z.string().optional().nullable(), + workspaceRuntime: z.record(z.unknown()).optional().nullable(), + desiredState: z.enum(["running", "stopped"]).optional().nullable(), +}).strict(); + +export const executionWorkspaceCloseReadinessStateSchema = z.enum([ + "ready", + "ready_with_warnings", + "blocked", +]); + +export const executionWorkspaceCloseActionKindSchema = z.enum([ + "archive_record", + "stop_runtime_services", + "cleanup_command", + "teardown_command", + "git_worktree_remove", + "git_branch_delete", + "remove_local_directory", +]); + +export const executionWorkspaceCloseActionSchema = z.object({ + kind: executionWorkspaceCloseActionKindSchema, + label: z.string(), + description: z.string(), + command: z.string().nullable(), +}).strict(); + +export const executionWorkspaceCloseLinkedIssueSchema = z.object({ + id: z.string().uuid(), + identifier: z.string().nullable(), + title: z.string(), + status: z.string(), + isTerminal: z.boolean(), +}).strict(); + +export const executionWorkspaceCloseGitReadinessSchema = z.object({ + repoRoot: z.string().nullable(), + workspacePath: z.string().nullable(), + branchName: z.string().nullable(), + baseRef: z.string().nullable(), + hasDirtyTrackedFiles: z.boolean(), + hasUntrackedFiles: z.boolean(), + dirtyEntryCount: z.number().int().nonnegative(), + untrackedEntryCount: z.number().int().nonnegative(), + aheadCount: z.number().int().nonnegative().nullable(), + behindCount: z.number().int().nonnegative().nullable(), + isMergedIntoBase: z.boolean().nullable(), + createdByRuntime: z.boolean(), +}).strict(); + +export const workspaceRuntimeServiceSchema = z.object({ + id: z.string(), + companyId: z.string().uuid(), + projectId: z.string().uuid().nullable(), + projectWorkspaceId: z.string().uuid().nullable(), + executionWorkspaceId: z.string().uuid().nullable(), + issueId: z.string().uuid().nullable(), + scopeType: z.enum(["project_workspace", "execution_workspace", "run", "agent"]), + scopeId: z.string().nullable(), + serviceName: z.string(), + status: z.enum(["starting", "running", "stopped", "failed"]), + lifecycle: z.enum(["shared", "ephemeral"]), + reuseKey: z.string().nullable(), + command: z.string().nullable(), + cwd: z.string().nullable(), + port: z.number().int().nullable(), + url: z.string().nullable(), + provider: z.enum(["local_process", "adapter_managed"]), + providerRef: z.string().nullable(), + ownerAgentId: z.string().uuid().nullable(), + startedByRunId: z.string().uuid().nullable(), + lastUsedAt: z.coerce.date(), + startedAt: z.coerce.date(), + stoppedAt: z.coerce.date().nullable(), + stopPolicy: z.record(z.unknown()).nullable(), + healthStatus: z.enum(["unknown", "healthy", "unhealthy"]), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}).strict(); + +export const executionWorkspaceCloseReadinessSchema = z.object({ + workspaceId: z.string().uuid(), + state: executionWorkspaceCloseReadinessStateSchema, + blockingReasons: z.array(z.string()), + warnings: z.array(z.string()), + linkedIssues: z.array(executionWorkspaceCloseLinkedIssueSchema), + plannedActions: z.array(executionWorkspaceCloseActionSchema), + isDestructiveCloseAllowed: z.boolean(), + isSharedWorkspace: z.boolean(), + isProjectPrimaryWorkspace: z.boolean(), + git: executionWorkspaceCloseGitReadinessSchema.nullable(), + runtimeServices: z.array(workspaceRuntimeServiceSchema), +}).strict(); + export const updateExecutionWorkspaceSchema = z.object({ + name: z.string().min(1).optional(), + cwd: z.string().optional().nullable(), + repoUrl: z.string().optional().nullable(), + baseRef: z.string().optional().nullable(), + branchName: z.string().optional().nullable(), + providerRef: z.string().optional().nullable(), status: executionWorkspaceStatusSchema.optional(), cleanupEligibleAt: z.string().datetime().optional().nullable(), cleanupReason: z.string().optional().nullable(), + config: executionWorkspaceConfigSchema.optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(), }).strict(); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 77540c88..8d808ab0 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -85,6 +85,7 @@ export { upsertAgentInstructionsFileSchema, updateAgentInstructionsPathSchema, createAgentKeySchema, + agentMineInboxQuerySchema, wakeAgentSchema, resetAgentSessionSchema, testAdapterEnvironmentSchema, @@ -97,6 +98,7 @@ export { type UpsertAgentInstructionsFile, type UpdateAgentInstructionsPath, type CreateAgentKey, + type AgentMineInboxQuery, type WakeAgent, type ResetAgentSession, type TestAdapterEnvironment, @@ -109,6 +111,7 @@ export { createProjectWorkspaceSchema, updateProjectWorkspaceSchema, projectExecutionWorkspacePolicySchema, + projectWorkspaceRuntimeConfigSchema, type CreateProject, type UpdateProject, type CreateProjectWorkspace, @@ -153,8 +156,15 @@ export { } from "./work-product.js"; export { + executionWorkspaceConfigSchema, updateExecutionWorkspaceSchema, executionWorkspaceStatusSchema, + executionWorkspaceCloseActionKindSchema, + executionWorkspaceCloseActionSchema, + executionWorkspaceCloseGitReadinessSchema, + executionWorkspaceCloseLinkedIssueSchema, + executionWorkspaceCloseReadinessSchema, + executionWorkspaceCloseReadinessStateSchema, type UpdateExecutionWorkspace, } from "./execution-workspace.js"; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index a98c5b04..a5e3d2a2 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -32,6 +32,7 @@ export const createIssueSchema = z.object({ projectWorkspaceId: z.string().uuid().optional().nullable(), goalId: z.string().uuid().optional().nullable(), parentId: z.string().uuid().optional().nullable(), + inheritExecutionWorkspaceFromIssueId: z.string().uuid().optional().nullable(), title: z.string().min(1), description: z.string().optional().nullable(), status: z.enum(ISSUE_STATUSES).optional().default("backlog"), @@ -66,6 +67,7 @@ export type CreateIssueLabel = z.infer; export const updateIssueSchema = createIssueSchema.partial().extend({ comment: z.string().min(1).optional(), reopen: z.boolean().optional(), + interrupt: z.boolean().optional(), hiddenAt: z.string().datetime().nullable().optional(), }); diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index cf5aba8a..89308ff4 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -27,6 +27,11 @@ export const projectExecutionWorkspacePolicySchema = z }) .strict(); +export const projectWorkspaceRuntimeConfigSchema = z.object({ + workspaceRuntime: z.record(z.unknown()).optional().nullable(), + desiredState: z.enum(["running", "stopped"]).optional().nullable(), +}).strict(); + const projectWorkspaceSourceTypeSchema = z.enum(["local_path", "git_repo", "remote_managed", "non_git_path"]); const projectWorkspaceVisibilitySchema = z.enum(["default", "advanced"]); @@ -44,6 +49,7 @@ const projectWorkspaceFields = { remoteWorkspaceRef: z.string().optional().nullable(), sharedWorkspaceKey: z.string().optional().nullable(), metadata: z.record(z.unknown()).optional().nullable(), + runtimeConfig: projectWorkspaceRuntimeConfigSchema.optional().nullable(), }; function validateProjectWorkspace(value: Record, ctx: z.RefinementCtx) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf66edb7..19c9ffc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -504,8 +504,8 @@ importers: specifier: ^5.1.0 version: 5.2.1 hermes-paperclip-adapter: - specifier: 0.1.1 - version: 0.1.1 + specifier: ^0.2.0 + version: 0.2.0 jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@2.0.1) @@ -639,6 +639,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + hermes-paperclip-adapter: + specifier: ^0.2.0 + version: 0.2.0 lexical: specifier: 0.35.0 version: 0.35.0 @@ -2040,8 +2043,8 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - '@paperclipai/adapter-utils@0.3.1': - resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==} + '@paperclipai/adapter-utils@2026.325.0': + resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -4468,8 +4471,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hermes-paperclip-adapter@0.1.1: - resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==} + hermes-paperclip-adapter@0.2.0: + resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==} engines: {node: '>=20.0.0'} html-encoding-sniffer@6.0.0: @@ -7740,7 +7743,7 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} - '@paperclipai/adapter-utils@0.3.1': {} + '@paperclipai/adapter-utils@2026.325.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -10337,9 +10340,9 @@ snapshots: help-me@5.0.0: {} - hermes-paperclip-adapter@0.1.1: + hermes-paperclip-adapter@0.2.0: dependencies: - '@paperclipai/adapter-utils': 0.3.1 + '@paperclipai/adapter-utils': 2026.325.0 picocolors: 1.1.1 html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts new file mode 100644 index 00000000..aed49c1b --- /dev/null +++ b/scripts/dev-runner.ts @@ -0,0 +1,656 @@ +#!/usr/bin/env -S node --import tsx +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; +import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; +import { + findAdoptableLocalService, + removeLocalServiceRegistryRecord, + touchLocalServiceRegistryRecord, + writeLocalServiceRegistryRecord, +} from "../server/src/services/local-service-supervisor.ts"; + +const mode = process.argv[2] === "watch" ? "watch" : "dev"; +const cliArgs = process.argv.slice(3); +const scanIntervalMs = 1500; +const autoRestartPollIntervalMs = 2500; +const gracefulShutdownTimeoutMs = 10_000; +const changedPathSampleLimit = 5; +const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); + +const watchedDirectories = [ + "cli", + "scripts", + "server", + "packages/adapter-utils", + "packages/adapters", + "packages/db", + "packages/plugins/sdk", + "packages/shared", +].map((relativePath) => path.join(repoRoot, relativePath)); + +const watchedFiles = [ + ".env", + "package.json", + "pnpm-workspace.yaml", + "tsconfig.base.json", + "tsconfig.json", + "vitest.config.ts", +].map((relativePath) => path.join(repoRoot, relativePath)); + +const ignoredDirectoryNames = new Set([ + ".git", + ".turbo", + ".vite", + "coverage", + "dist", + "node_modules", + "ui-dist", +]); + +const ignoredRelativePaths = new Set([ + ".paperclip/dev-server-status.json", +]); + +const tailscaleAuthFlagNames = new Set([ + "--tailscale-auth", + "--authenticated-private", +]); + +let tailscaleAuth = false; +const forwardedArgs: string[] = []; + +for (const arg of cliArgs) { + if (tailscaleAuthFlagNames.has(arg)) { + tailscaleAuth = true; + continue; + } + forwardedArgs.push(arg); +} + +if (process.env.npm_config_tailscale_auth === "true") { + tailscaleAuth = true; +} +if (process.env.npm_config_authenticated_private === "true") { + tailscaleAuth = true; +} + +const env: NodeJS.ProcessEnv = { + ...process.env, + PAPERCLIP_UI_DEV_MIDDLEWARE: "true", +}; + +if (mode === "dev") { + env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; + env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; +} + +if (mode === "watch") { + env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; + env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; +} + +if (tailscaleAuth) { + env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated"; + env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private"; + env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto"; + env.HOST = "0.0.0.0"; + console.log("[paperclip] dev mode: authenticated/private (tailscale-friendly) on 0.0.0.0"); +} else { + console.log("[paperclip] dev mode: local_trusted (default)"); +} + +const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) || 3100; +const devService = createDevServiceIdentity({ + mode, + forwardedArgs, + tailscaleAuth, + port: serverPort, +}); + +const existingRunner = await findAdoptableLocalService({ + serviceKey: devService.serviceKey, + cwd: repoRoot, + envFingerprint: devService.envFingerprint, + port: serverPort, +}); +if (existingRunner) { + console.log( + `[paperclip] ${devService.serviceName} already running (pid ${existingRunner.pid}${typeof existingRunner.metadata?.childPid === "number" ? `, child ${existingRunner.metadata.childPid}` : ""})`, + ); + process.exit(0); +} + +const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +let previousSnapshot = collectWatchedSnapshot(); +let dirtyPaths = new Set(); +let pendingMigrations: string[] = []; +let lastChangedAt: string | null = null; +let lastRestartAt: string | null = null; +let scanInFlight = false; +let restartInFlight = false; +let shuttingDown = false; +let childExitWasExpected = false; +let child: ReturnType | null = null; +let childExitPromise: Promise<{ code: number; signal: NodeJS.Signals | null }> | null = null; +let scanTimer: ReturnType | null = null; +let autoRestartTimer: ReturnType | null = null; + +function toError(error: unknown, context = "Dev runner command failed") { + if (error instanceof Error) return error; + if (error === undefined) return new Error(context); + if (typeof error === "string") return new Error(`${context}: ${error}`); + + try { + return new Error(`${context}: ${JSON.stringify(error)}`); + } catch { + return new Error(`${context}: ${String(error)}`); + } +} + +process.on("uncaughtException", async (error) => { + await removeLocalServiceRegistryRecord(devService.serviceKey); + const err = toError(error, "Uncaught exception in dev runner"); + process.stderr.write(`${err.stack ?? err.message}\n`); + process.exit(1); +}); + +process.on("unhandledRejection", async (reason) => { + await removeLocalServiceRegistryRecord(devService.serviceKey); + const err = toError(reason, "Unhandled promise rejection in dev runner"); + process.stderr.write(`${err.stack ?? err.message}\n`); + process.exit(1); +}); + +function formatPendingMigrationSummary(migrations: string[]) { + if (migrations.length === 0) return "none"; + return migrations.length > 3 + ? `${migrations.slice(0, 3).join(", ")} (+${migrations.length - 3} more)` + : migrations.join(", "); +} + +function exitForSignal(signal: NodeJS.Signals) { + if (signal === "SIGINT") { + process.exit(130); + } + if (signal === "SIGTERM") { + process.exit(143); + } + process.exit(1); +} + +function toRelativePath(absolutePath: string) { + return path.relative(repoRoot, absolutePath).split(path.sep).join("/"); +} + +function readSignature(absolutePath: string) { + const stats = statSync(absolutePath); + return `${Math.trunc(stats.mtimeMs)}:${stats.size}`; +} + +function addFileToSnapshot(snapshot: Map, absolutePath: string) { + const relativePath = toRelativePath(absolutePath); + if (ignoredRelativePaths.has(relativePath)) return; + if (!shouldTrackDevServerPath(relativePath)) return; + snapshot.set(relativePath, readSignature(absolutePath)); +} + +function walkDirectory(snapshot: Map, absoluteDirectory: string) { + if (!existsSync(absoluteDirectory)) return; + + for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { + if (ignoredDirectoryNames.has(entry.name)) continue; + + const absolutePath = path.join(absoluteDirectory, entry.name); + if (entry.isDirectory()) { + walkDirectory(snapshot, absolutePath); + continue; + } + if (entry.isFile() || entry.isSymbolicLink()) { + addFileToSnapshot(snapshot, absolutePath); + } + } +} + +function collectWatchedSnapshot() { + const snapshot = new Map(); + + for (const absoluteDirectory of watchedDirectories) { + walkDirectory(snapshot, absoluteDirectory); + } + for (const absoluteFile of watchedFiles) { + if (!existsSync(absoluteFile)) continue; + addFileToSnapshot(snapshot, absoluteFile); + } + + return snapshot; +} + +function diffSnapshots(previous: Map, next: Map) { + const changed = new Set(); + + for (const [relativePath, signature] of next) { + if (previous.get(relativePath) !== signature) { + changed.add(relativePath); + } + } + for (const relativePath of previous.keys()) { + if (!next.has(relativePath)) { + changed.add(relativePath); + } + } + + return [...changed].sort(); +} + +function ensureDevStatusDirectory() { + mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true }); +} + +function writeDevServerStatus() { + if (mode !== "dev") return; + + ensureDevStatusDirectory(); + const changedPaths = [...dirtyPaths].sort(); + writeFileSync( + devServerStatusFilePath, + `${JSON.stringify({ + dirty: changedPaths.length > 0 || pendingMigrations.length > 0, + lastChangedAt, + changedPathCount: changedPaths.length, + changedPathsSample: changedPaths.slice(0, changedPathSampleLimit), + pendingMigrations, + lastRestartAt, + }, null, 2)}\n`, + "utf8", + ); +} + +function clearDevServerStatus() { + if (mode !== "dev") return; + rmSync(devServerStatusFilePath, { force: true }); +} + +async function updateDevServiceRecord(extra?: Record) { + await writeLocalServiceRegistryRecord({ + version: 1, + serviceKey: devService.serviceKey, + profileKind: "paperclip-dev", + serviceName: devService.serviceName, + command: "dev-runner.ts", + cwd: repoRoot, + envFingerprint: devService.envFingerprint, + port: serverPort, + url: `http://127.0.0.1:${serverPort}`, + pid: process.pid, + processGroupId: null, + provider: "local_process", + runtimeServiceId: null, + reuseKey: null, + startedAt: lastRestartAt ?? new Date().toISOString(), + lastSeenAt: new Date().toISOString(), + metadata: { + repoRoot, + mode, + childPid: child?.pid ?? null, + url: `http://127.0.0.1:${serverPort}`, + ...extra, + }, + }); +} + +async function runPnpm(args: string[], options: { + stdio?: "inherit" | ["ignore", "pipe", "pipe"]; + env?: NodeJS.ProcessEnv; + cwd?: string; +} = {}) { + return await new Promise<{ code: number; signal: NodeJS.Signals | null; stdout: string; stderr: string }>((resolve, reject) => { + const spawned = spawn(pnpmBin, args, { + stdio: options.stdio ?? ["ignore", "pipe", "pipe"], + env: options.env ?? process.env, + cwd: options.cwd, + shell: process.platform === "win32", + }); + + let stdoutBuffer = ""; + let stderrBuffer = ""; + + if (spawned.stdout) { + spawned.stdout.on("data", (chunk) => { + stdoutBuffer += String(chunk); + }); + } + if (spawned.stderr) { + spawned.stderr.on("data", (chunk) => { + stderrBuffer += String(chunk); + }); + } + + spawned.on("error", reject); + spawned.on("exit", (code, signal) => { + resolve({ + code: code ?? 0, + signal, + stdout: stdoutBuffer, + stderr: stderrBuffer, + }); + }); + }); +} + +async function getMigrationStatusPayload() { + const status = await runPnpm( + ["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"], + { env }, + ); + if (status.code !== 0) { + process.stderr.write( + status.stderr || + status.stdout || + `[paperclip] Command failed with code ${status.code}: pnpm --filter @paperclipai/db exec tsx src/migration-status.ts --json\n`, + ); + process.exit(status.code); + } + + try { + return JSON.parse(status.stdout.trim()) as { status?: string; pendingMigrations?: string[] }; + } catch (error) { + process.stderr.write( + status.stderr || + status.stdout || + "[paperclip] migration-status returned invalid JSON payload\n", + ); + throw toError(error, "Unable to parse migration-status JSON output"); + } +} + +async function refreshPendingMigrations() { + const payload = await getMigrationStatusPayload(); + pendingMigrations = + payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations) + ? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0) + : []; + writeDevServerStatus(); + return payload; +} + +async function maybePreflightMigrations(options: { interactive?: boolean; autoApply?: boolean; exitOnDecline?: boolean } = {}) { + const interactive = options.interactive ?? mode === "watch"; + const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true"; + const exitOnDecline = options.exitOnDecline ?? mode === "watch"; + + const payload = await refreshPendingMigrations(); + if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) { + return; + } + + let shouldApply = autoApply; + + if (!autoApply && interactive) { + if (!stdin.isTTY || !stdout.isTTY) { + shouldApply = true; + } else { + const prompt = createInterface({ input: stdin, output: stdout }); + try { + const answer = ( + await prompt.question( + `Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `, + ) + ) + .trim() + .toLowerCase(); + shouldApply = answer === "y" || answer === "yes"; + } finally { + prompt.close(); + } + } + } + + if (!shouldApply) { + if (exitOnDecline) { + process.stderr.write( + `[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). Refusing to start watch mode against a stale schema.\n`, + ); + process.exit(1); + } + return; + } + + const exit = await runPnpm(["db:migrate"], { + stdio: "inherit", + env, + cwd: repoRoot, + }); + if (exit.signal) { + exitForSignal(exit.signal); + return; + } + if (exit.code !== 0) { + process.exit(exit.code); + } + + await refreshPendingMigrations(); +} + +async function buildPluginSdk() { + console.log("[paperclip] building plugin sdk..."); + const result = await runPnpm( + ["--filter", "@paperclipai/plugin-sdk", "build"], + { stdio: "inherit" }, + ); + if (result.signal) { + exitForSignal(result.signal); + return; + } + if (result.code !== 0) { + console.error("[paperclip] plugin sdk build failed"); + process.exit(result.code); + } +} + +async function markChildAsCurrent() { + previousSnapshot = collectWatchedSnapshot(); + dirtyPaths = new Set(); + lastChangedAt = null; + lastRestartAt = new Date().toISOString(); + await refreshPendingMigrations(); + await updateDevServiceRecord(); +} + +async function scanForBackendChanges() { + if (mode !== "dev" || scanInFlight || restartInFlight) return; + scanInFlight = true; + try { + const nextSnapshot = collectWatchedSnapshot(); + const changed = diffSnapshots(previousSnapshot, nextSnapshot); + previousSnapshot = nextSnapshot; + if (changed.length === 0) return; + + for (const relativePath of changed) { + dirtyPaths.add(relativePath); + } + lastChangedAt = new Date().toISOString(); + await refreshPendingMigrations(); + } finally { + scanInFlight = false; + } +} + +async function getDevHealthPayload() { + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); + if (!response.ok) { + throw new Error(`Health request failed (${response.status})`); + } + return await response.json(); +} + +async function waitForChildExit() { + if (!childExitPromise) { + return { code: 0, signal: null }; + } + return await childExitPromise; +} + +async function stopChildForRestart() { + if (!child) return { code: 0, signal: null }; + childExitWasExpected = true; + child.kill("SIGTERM"); + const killTimer = setTimeout(() => { + if (child) { + child.kill("SIGKILL"); + } + }, gracefulShutdownTimeoutMs); + try { + return await waitForChildExit(); + } finally { + clearTimeout(killTimer); + } +} + +async function startServerChild() { + await buildPluginSdk(); + + const serverScript = mode === "watch" ? "dev:watch" : "dev"; + child = spawn( + pnpmBin, + ["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], + { stdio: "inherit", env, shell: process.platform === "win32" }, + ); + + childExitPromise = new Promise((resolve, reject) => { + child?.on("error", reject); + child?.on("exit", (code, signal) => { + const expected = childExitWasExpected; + childExitWasExpected = false; + child = null; + childExitPromise = null; + void touchLocalServiceRegistryRecord(devService.serviceKey, { + metadata: { + repoRoot, + mode, + childPid: null, + url: `http://127.0.0.1:${serverPort}`, + }, + }); + resolve({ code: code ?? 0, signal }); + + if (restartInFlight || expected || shuttingDown) { + return; + } + if (signal) { + exitForSignal(signal); + return; + } + process.exit(code ?? 0); + }); + }); + + await markChildAsCurrent(); +} + +async function maybeAutoRestartChild() { + if (mode !== "dev" || restartInFlight || !child) return; + if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return; + + restartInFlight = true; + let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null; + try { + health = await getDevHealthPayload(); + } catch { + restartInFlight = false; + return; + } + + const devServer = health?.devServer; + if (!devServer?.enabled || devServer.autoRestartEnabled !== true) { + restartInFlight = false; + return; + } + if ((devServer.activeRunCount ?? 0) > 0) { + restartInFlight = false; + return; + } + + try { + await maybePreflightMigrations({ + autoApply: true, + interactive: false, + exitOnDecline: false, + }); + await stopChildForRestart(); + await startServerChild(); + } catch (error) { + const err = toError(error, "Auto-restart failed"); + process.stderr.write(`${err.stack ?? err.message}\n`); + process.exit(1); + } finally { + restartInFlight = false; + } +} + +function installDevIntervals() { + if (mode !== "dev") return; + + scanTimer = setInterval(() => { + void scanForBackendChanges(); + }, scanIntervalMs); + autoRestartTimer = setInterval(() => { + void maybeAutoRestartChild(); + }, autoRestartPollIntervalMs); +} + +function clearDevIntervals() { + if (scanTimer) { + clearInterval(scanTimer); + scanTimer = null; + } + if (autoRestartTimer) { + clearInterval(autoRestartTimer); + autoRestartTimer = null; + } +} + +async function shutdown(signal: NodeJS.Signals) { + if (shuttingDown) return; + shuttingDown = true; + clearDevIntervals(); + clearDevServerStatus(); + await removeLocalServiceRegistryRecord(devService.serviceKey); + + if (!child) { + exitForSignal(signal); + return; + } + + childExitWasExpected = true; + child.kill(signal); + const exit = await waitForChildExit(); + if (exit.signal) { + exitForSignal(exit.signal); + return; + } + process.exit(exit.code ?? 0); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT"); +}); +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +await maybePreflightMigrations(); +await startServerChild(); +installDevIntervals(); + +if (mode === "watch") { + const exit = await waitForChildExit(); + await removeLocalServiceRegistryRecord(devService.serviceKey); + if (exit.signal) { + exitForSignal(exit.signal); + } + process.exit(exit.code ?? 0); +} diff --git a/scripts/dev-service-profile.ts b/scripts/dev-service-profile.ts new file mode 100644 index 00000000..9c129b34 --- /dev/null +++ b/scripts/dev-service-profile.ts @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createLocalServiceKey } from "../server/src/services/local-service-supervisor.ts"; + +export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export function createDevServiceIdentity(input: { + mode: "watch" | "dev"; + forwardedArgs: string[]; + tailscaleAuth: boolean; + port: number; +}) { + const envFingerprint = createHash("sha256") + .update( + JSON.stringify({ + mode: input.mode, + forwardedArgs: input.forwardedArgs, + tailscaleAuth: input.tailscaleAuth, + port: input.port, + }), + ) + .digest("hex"); + + const serviceName = input.mode === "watch" ? "paperclip-dev-watch" : "paperclip-dev-once"; + const serviceKey = createLocalServiceKey({ + profileKind: "paperclip-dev", + serviceName, + cwd: repoRoot, + command: "dev-runner.ts", + envFingerprint, + port: input.port, + scope: { + repoRoot, + mode: input.mode, + }, + }); + + return { + serviceKey, + serviceName, + envFingerprint, + }; +} diff --git a/scripts/dev-service.ts b/scripts/dev-service.ts new file mode 100644 index 00000000..978607ec --- /dev/null +++ b/scripts/dev-service.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env -S node --import tsx +import { listLocalServiceRegistryRecords, removeLocalServiceRegistryRecord, terminateLocalService } from "../server/src/services/local-service-supervisor.ts"; +import { repoRoot } from "./dev-service-profile.ts"; + +function toDisplayLines(records: Awaited>) { + return records.map((record) => { + const childPid = typeof record.metadata?.childPid === "number" ? ` child=${record.metadata.childPid}` : ""; + const url = typeof record.metadata?.url === "string" ? ` url=${record.metadata.url}` : ""; + return `${record.serviceName} pid=${record.pid}${childPid} cwd=${record.cwd}${url}`; + }); +} + +const command = process.argv[2] ?? "list"; +const records = await listLocalServiceRegistryRecords({ + profileKind: "paperclip-dev", + metadata: { repoRoot }, +}); + +if (command === "list") { + if (records.length === 0) { + console.log("No Paperclip dev services registered for this repo."); + process.exit(0); + } + for (const line of toDisplayLines(records)) { + console.log(line); + } + process.exit(0); +} + +if (command === "stop") { + if (records.length === 0) { + console.log("No Paperclip dev services registered for this repo."); + process.exit(0); + } + for (const record of records) { + await terminateLocalService(record); + await removeLocalServiceRegistryRecord(record.serviceKey); + console.log(`Stopped ${record.serviceName} (pid ${record.pid})`); + } + process.exit(0); +} + +console.error(`Unknown dev-service command: ${command}`); +process.exit(1); diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts new file mode 100644 index 00000000..430ba589 --- /dev/null +++ b/scripts/ensure-workspace-package-links.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env -S node --import tsx +import { spawn } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { repoRoot } from "./dev-service-profile.ts"; + +type WorkspaceLinkMismatch = { + packageName: string; + expectedPath: string; + actualPath: string | null; +}; + +function readJsonFile(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; +} + +function discoverWorkspacePackagePaths(rootDir: string): Map { + const packagePaths = new Map(); + const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]); + + function visit(dirPath: string) { + const packageJsonPath = path.join(dirPath, "package.json"); + if (existsSync(packageJsonPath)) { + const packageJson = readJsonFile(packageJsonPath); + if (typeof packageJson.name === "string" && packageJson.name.length > 0) { + packagePaths.set(packageJson.name, dirPath); + } + } + + for (const entry of readdirSync(dirPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (ignoredDirNames.has(entry.name)) continue; + visit(path.join(dirPath, entry.name)); + } + } + + visit(path.join(rootDir, "packages")); + visit(path.join(rootDir, "server")); + visit(path.join(rootDir, "ui")); + visit(path.join(rootDir, "cli")); + + return packagePaths; +} + +const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot); + +function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] { + const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json")); + const dependencies = { + ...(serverPackageJson.dependencies as Record | undefined), + ...(serverPackageJson.devDependencies as Record | undefined), + }; + const mismatches: WorkspaceLinkMismatch[] = []; + + for (const [packageName, version] of Object.entries(dependencies)) { + if (typeof version !== "string" || !version.startsWith("workspace:")) continue; + + const expectedPath = workspacePackagePaths.get(packageName); + if (!expectedPath) continue; + + const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/")); + const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null; + if (actualPath === path.resolve(expectedPath)) continue; + + mismatches.push({ + packageName, + expectedPath: path.resolve(expectedPath), + actualPath, + }); + } + + return mismatches; +} + +function runCommand(command: string, args: string[], cwd: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: process.env, + stdio: "inherit", + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new Error( + `${command} ${args.join(" ")} failed with ${signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`}`, + ), + ); + }); + }); +} + +async function ensureServerWorkspaceLinksCurrent() { + const mismatches = findServerWorkspaceLinkMismatches(); + if (mismatches.length === 0) return; + + console.log("[paperclip] detected stale workspace package links for server; relinking dependencies..."); + for (const mismatch of mismatches) { + console.log( + `[paperclip] ${mismatch.packageName}: ${mismatch.actualPath ?? "missing"} -> ${mismatch.expectedPath}`, + ); + } + + const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + await runCommand( + pnpmBin, + ["install", "--force", "--config.confirmModulesPurge=false"], + repoRoot, + ); + + const remainingMismatches = findServerWorkspaceLinkMismatches(); + if (remainingMismatches.length === 0) return; + + throw new Error( + `Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, + ); +} + +await ensureServerWorkspaceLinksCurrent(); diff --git a/scripts/generate-ui-package-json.mjs b/scripts/generate-ui-package-json.mjs new file mode 100644 index 00000000..e8eac306 --- /dev/null +++ b/scripts/generate-ui-package-json.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const uiDir = join(repoRoot, "ui"); +const packageJsonPath = join(uiDir, "package.json"); + +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + +const publishPackageJson = { + name: packageJson.name, + version: packageJson.version, + description: packageJson.description, + license: packageJson.license, + homepage: packageJson.homepage, + bugs: packageJson.bugs, + repository: packageJson.repository, + type: packageJson.type, + files: ["dist"], + publishConfig: { + access: "public", + }, +}; + +writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`); + +console.log(" ✓ Generated publishable UI package.json"); diff --git a/scripts/kill-dev.sh b/scripts/kill-dev.sh index 2cb946e2..9a53498a 100755 --- a/scripts/kill-dev.sh +++ b/scripts/kill-dev.sh @@ -8,64 +8,199 @@ # set -euo pipefail +shopt -s nullglob DRY_RUN=false if [[ "${1:-}" == "--dry" || "${1:-}" == "--dry-run" || "${1:-}" == "-n" ]]; then DRY_RUN=true fi -# Collect PIDs of node processes running from any paperclip directory. -# Matches paths like /Users/*/paperclip/... or /Users/*/paperclip-*/... -# Excludes postgres-related processes. -pids=() -lines=() +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_PARENT="$(dirname "$REPO_ROOT")" + +node_pids=() +node_lines=() +pg_pids=() +pg_pidfiles=() +pg_data_dirs=() + +is_pid_running() { + local pid="$1" + kill -0 "$pid" 2>/dev/null +} + +read_pidfile_pid() { + local pidfile="$1" + local first_line + first_line="$(head -n 1 "$pidfile" 2>/dev/null | tr -d '[:space:]' || true)" + if [[ "$first_line" =~ ^[0-9]+$ ]] && (( first_line > 0 )); then + printf '%s\n' "$first_line" + return 0 + fi + return 1 +} + +command_for_pid() { + local pid="$1" + ps -o command= -p "$pid" 2>/dev/null || true +} + +append_postgres_from_pidfile() { + local pidfile="$1" + local pid cmd data_dir + pid="$(read_pidfile_pid "$pidfile" || true)" + [[ -n "$pid" ]] || return 0 + is_pid_running "$pid" || return 0 + cmd="$(command_for_pid "$pid")" + [[ "$cmd" == *postgres* ]] || return 0 + + for existing_pid in "${pg_pids[@]:-}"; do + [[ "$existing_pid" == "$pid" ]] && return 0 + done + + data_dir="$(dirname "$pidfile")" + pg_pids+=("$pid") + pg_pidfiles+=("$pidfile") + pg_data_dirs+=("$data_dir") +} + +wait_for_pid_exit() { + local pid="$1" + local timeout_sec="$2" + local waited=0 + while is_pid_running "$pid"; do + if (( waited >= timeout_sec * 10 )); then + return 1 + fi + sleep 0.1 + ((waited += 1)) + done + return 0 +} while IFS= read -r line; do [[ -z "$line" ]] && continue - # skip postgres processes [[ "$line" == *postgres* ]] && continue pid=$(echo "$line" | awk '{print $2}') - pids+=("$pid") - lines+=("$line") + node_pids+=("$pid") + node_lines+=("$line") done < <(ps aux | grep -E '/paperclip(-[^/]+)?/' | grep node | grep -v grep || true) -if [[ ${#pids[@]} -eq 0 ]]; then +candidate_pidfiles=() +candidate_pidfiles+=( + "$HOME"/.paperclip/instances/*/db/postmaster.pid + "$REPO_ROOT"/.paperclip/instances/*/db/postmaster.pid + "$REPO_ROOT"/.paperclip/runtime-services/instances/*/db/postmaster.pid +) + +for sibling_root in "$REPO_PARENT"/paperclip*; do + [[ -d "$sibling_root" ]] || continue + candidate_pidfiles+=( + "$sibling_root"/.paperclip/instances/*/db/postmaster.pid + "$sibling_root"/.paperclip/runtime-services/instances/*/db/postmaster.pid + ) +done + +for pidfile in "${candidate_pidfiles[@]:-}"; do + [[ -f "$pidfile" ]] || continue + append_postgres_from_pidfile "$pidfile" +done + +if [[ ${#node_pids[@]} -eq 0 && ${#pg_pids[@]} -eq 0 ]]; then echo "No Paperclip dev processes found." exit 0 fi -echo "Found ${#pids[@]} Paperclip dev process(es):" -echo "" +if [[ ${#node_pids[@]} -gt 0 ]]; then + echo "Found ${#node_pids[@]} Paperclip dev node process(es):" + echo "" -for i in "${!pids[@]}"; do - line="${lines[$i]}" - pid=$(echo "$line" | awk '{print $2}') - start=$(echo "$line" | awk '{print $9}') - cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') - # Shorten the command for readability - cmd=$(echo "$cmd" | sed "s|$HOME/||g") - printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" -done + for i in "${!node_pids[@]:-}"; do + line="${node_lines[$i]}" + pid=$(echo "$line" | awk '{print $2}') + start=$(echo "$line" | awk '{print $9}') + cmd=$(echo "$line" | awk '{for(i=11;i<=NF;i++) printf "%s ", $i; print ""}') + cmd=$(echo "$cmd" | sed "s|$HOME/||g") + printf " PID %-7s started %-10s %s\n" "$pid" "$start" "$cmd" + done -echo "" + echo "" +fi + +if [[ ${#pg_pids[@]} -gt 0 ]]; then + echo "Found ${#pg_pids[@]} embedded PostgreSQL master process(es):" + echo "" + + for i in "${!pg_pids[@]:-}"; do + pid="${pg_pids[$i]}" + data_dir="${pg_data_dirs[$i]}" + pidfile="${pg_pidfiles[$i]}" + short_data_dir="${data_dir/#$HOME\//}" + short_pidfile="${pidfile/#$HOME\//}" + printf " PID %-7s data %-55s pidfile %s\n" "$pid" "$short_data_dir" "$short_pidfile" + done + + echo "" +fi if [[ "$DRY_RUN" == true ]]; then echo "Dry run — re-run without --dry to kill these processes." exit 0 fi -echo "Sending SIGTERM..." -for pid in "${pids[@]}"; do - kill "$pid" 2>/dev/null && echo " killed $pid" || echo " $pid already gone" -done +if [[ ${#node_pids[@]} -gt 0 ]]; then + echo "Sending SIGTERM to Paperclip node processes..." + for pid in "${node_pids[@]}"; do + kill -TERM "$pid" 2>/dev/null && echo " signaled $pid" || echo " $pid already gone" + done + echo "Waiting briefly for node processes to exit..." + sleep 2 +fi -# Give processes a moment to exit, then SIGKILL any stragglers -sleep 2 -for pid in "${pids[@]}"; do - if kill -0 "$pid" 2>/dev/null; then - echo " $pid still alive, sending SIGKILL..." - kill -9 "$pid" 2>/dev/null || true +leftover_pg_pids=() +leftover_pg_data_dirs=() +for i in "${!pg_pids[@]:-}"; do + pid="${pg_pids[$i]}" + if is_pid_running "$pid"; then + leftover_pg_pids+=("$pid") + leftover_pg_data_dirs+=("${pg_data_dirs[$i]}") fi done +if [[ ${#leftover_pg_pids[@]} -gt 0 ]]; then + echo "Sending SIGTERM to leftover embedded PostgreSQL processes..." + for i in "${!leftover_pg_pids[@]:-}"; do + pid="${leftover_pg_pids[$i]}" + data_dir="${leftover_pg_data_dirs[$i]}" + kill -TERM "$pid" 2>/dev/null \ + && echo " signaled $pid ($data_dir)" \ + || echo " $pid already gone" + done + echo "Waiting up to 15s for PostgreSQL to shut down cleanly..." + for pid in "${leftover_pg_pids[@]:-}"; do + if wait_for_pid_exit "$pid" 15; then + echo " postgres $pid exited cleanly" + fi + done +fi + +if [[ ${#node_pids[@]} -gt 0 ]]; then + for pid in "${node_pids[@]:-}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " node $pid still alive, sending SIGKILL..." + kill -KILL "$pid" 2>/dev/null || true + fi + done +fi + +if [[ ${#pg_pids[@]} -gt 0 ]]; then + for pid in "${pg_pids[@]:-}"; do + if kill -0 "$pid" 2>/dev/null; then + echo " postgres $pid still alive, sending SIGKILL..." + kill -KILL "$pid" 2>/dev/null || true + fi + done +fi + echo "Done." diff --git a/scripts/paperclip-commit-metrics.ts b/scripts/paperclip-commit-metrics.ts new file mode 100644 index 00000000..8a648afa --- /dev/null +++ b/scripts/paperclip-commit-metrics.ts @@ -0,0 +1,883 @@ +#!/usr/bin/env npx tsx + +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_QUERY = "\"Co-Authored-By: Paperclip \""; +const DEFAULT_CACHE_FILE = path.resolve("data/paperclip-commit-metrics-cache.json"); +const DEFAULT_SEARCH_START = "2008-01-01T00:00:00Z"; +const SEARCH_WINDOW_LIMIT = 900; +const MIN_WINDOW_MS = 60_000; +const DEFAULT_STATS_FETCH_LIMIT = 250; +const DEFAULT_STATS_CONCURRENCY = 4; +const DEFAULT_SEARCH_FIELD = "committer-date"; +const PAPERCLIP_EMAIL = "noreply@paperclip.ing"; +const PAPERCLIP_NAME = "paperclip"; + +interface CliOptions { + cacheFile: string; + end: Date; + excludeOwners: string[]; + exportFormat: "csv" | "json"; + includePrivate: boolean; + json: boolean; + output: string | null; + query: string; + refreshSearch: boolean; + refreshStats: boolean; + searchField: "author-date" | "committer-date"; + start: Date; + statsConcurrency: number; + statsFetchLimit: number; + skipStats: boolean; +} + +interface SearchCommitItem { + author: { + login?: string; + } | null; + commit: { + author: { + date: string; + email: string | null; + name: string | null; + } | null; + message: string; + }; + html_url: string; + repository: { + full_name: string; + html_url: string; + }; + sha: string; +} + +interface CommitStats { + additions: number; + deletions: number; + total: number; +} + +interface CachedCommit { + authorEmail: string | null; + authorLogin: string | null; + authorName: string | null; + committedAt: string | null; + contributors: ContributorRecord[]; + htmlUrl: string; + repositoryFullName: string; + repositoryUrl: string; + sha: string; +} + +interface CachedCommitStats extends CommitStats { + fetchedAt: string; +} + +interface ContributorRecord { + displayName: string; + email: string | null; + key: string; + login: string | null; +} + +interface WindowCacheEntry { + completedAt: string; + key: string; + shas: string[]; + totalCount: number; +} + +interface CacheFile { + commits: Record; + queryKey: string; + searchField: CliOptions["searchField"]; + stats: Record; + updatedAt: string | null; + version: number; + windows: Record; +} + +interface SearchResponse { + incomplete_results: boolean; + items: SearchCommitItem[]; + total_count: number; +} + +interface SearchWindowResult { + shas: Set; + totalCount: number; +} + +interface Summary { + cacheFile: string; + contributors: { + count: number; + sample: ContributorRecord[]; + }; + detectedQuery: string; + lineStats: { + additions: number; + complete: boolean; + coveredCommits: number; + deletions: number; + missingCommits: number; + totalChanges: number; + }; + range: { + end: string; + searchField: CliOptions["searchField"]; + start: string; + }; + filters: { + excludedOwners: string[]; + }; + repos: { + count: number; + sample: string[]; + }; + statsFetch: { + fetchedThisRun: number; + skipped: boolean; + }; + totals: { + commits: number; + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const cache = await loadCache(options.cacheFile, options); + const client = new GitHubClient(await resolveGitHubToken()); + + const { shas } = await searchWindow(client, cache, options, options.start, options.end); + const sortedShas = [...shas].sort(); + + let fetchedThisRun = 0; + if (!options.skipStats) { + fetchedThisRun = await enrichCommitStats(client, cache, options, sortedShas); + } + + cache.updatedAt = new Date().toISOString(); + await saveCache(options.cacheFile, cache); + + const filteredShas = sortFilteredShas(cache, filterShas(cache, sortedShas, options)); + const summary = buildSummary(cache, options, filteredShas, fetchedThisRun); + + if (options.output) { + await writeExport(options.output, options.exportFormat, cache, filteredShas, summary); + } + + if (options.json) { + console.log(JSON.stringify(summary, null, 2)); + return; + } + + printSummary(summary); +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + cacheFile: DEFAULT_CACHE_FILE, + end: new Date(), + excludeOwners: [], + exportFormat: "csv", + includePrivate: false, + json: false, + output: null, + query: DEFAULT_QUERY, + refreshSearch: false, + refreshStats: false, + searchField: DEFAULT_SEARCH_FIELD, + start: new Date(DEFAULT_SEARCH_START), + statsConcurrency: DEFAULT_STATS_CONCURRENCY, + statsFetchLimit: DEFAULT_STATS_FETCH_LIMIT, + skipStats: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--cache-file": + options.cacheFile = requireValue(argv, ++index, arg); + break; + case "--end": + options.end = parseDateArg(requireValue(argv, ++index, arg), arg); + break; + case "--exclude-owner": + options.excludeOwners.push(requireValue(argv, ++index, arg).toLowerCase()); + break; + case "--export-format": { + const value = requireValue(argv, ++index, arg); + if (value !== "csv" && value !== "json") { + throw new Error(`Invalid --export-format value: ${value}`); + } + options.exportFormat = value; + break; + } + case "--include-private": + options.includePrivate = true; + break; + case "--json": + options.json = true; + break; + case "--output": + options.output = requireValue(argv, ++index, arg); + break; + case "--query": + options.query = requireValue(argv, ++index, arg); + break; + case "--refresh-search": + options.refreshSearch = true; + break; + case "--refresh-stats": + options.refreshStats = true; + break; + case "--search-field": { + const value = requireValue(argv, ++index, arg); + if (value !== "author-date" && value !== "committer-date") { + throw new Error(`Invalid --search-field value: ${value}`); + } + options.searchField = value; + break; + } + case "--skip-stats": + options.skipStats = true; + break; + case "--start": + options.start = parseDateArg(requireValue(argv, ++index, arg), arg); + break; + case "--stats-concurrency": + options.statsConcurrency = parsePositiveInt(requireValue(argv, ++index, arg), arg); + break; + case "--stats-fetch-limit": + options.statsFetchLimit = parseNonNegativeInt(requireValue(argv, ++index, arg), arg); + break; + case "--help": + printHelp(); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (Number.isNaN(options.start.getTime()) || Number.isNaN(options.end.getTime())) { + throw new Error("Invalid start or end date"); + } + if (options.start >= options.end) { + throw new Error("--start must be earlier than --end"); + } + + return options; +} + +function requireValue(argv: string[], index: number, flag: string): string { + const value = argv[index]; + if (!value) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + +function parseDateArg(value: string, flag: string): Date { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid date for ${flag}: ${value}`); + } + return parsed; +} + +function parsePositiveInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid positive integer for ${flag}: ${value}`); + } + return parsed; +} + +function parseNonNegativeInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid non-negative integer for ${flag}: ${value}`); + } + return parsed; +} + +function printHelp() { + console.log(`Usage: tsx scripts/paperclip-commit-metrics.ts [options] + +Options: + --start ISO date/time lower bound (default: ${DEFAULT_SEARCH_START}) + --end ISO date/time upper bound (default: now) + --query Commit search string (default: ${DEFAULT_QUERY}) + --search-field author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD}) + --include-private Include repos visible to the current token + --exclude-owner Exclude repositories owned by this GitHub owner/org (repeatable) + --cache-file Cache path (default: ${DEFAULT_CACHE_FILE}) + --skip-stats Skip additions/deletions enrichment + --stats-fetch-limit Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT}) + --stats-concurrency Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY}) + --output Write the full filtered result set to a file + --export-format csv | json for --output exports (default: csv) + --refresh-search Ignore cached search windows + --refresh-stats Re-fetch cached commit stats + --json Print JSON summary + --help Show this help +`); +} + +async function resolveGitHubToken(): Promise { + const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (envToken) { + return envToken; + } + + const { stdout } = await execFileAsync("gh", ["auth", "token"]); + const token = stdout.trim(); + if (!token) { + throw new Error("Unable to resolve a GitHub token. Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`."); + } + return token; +} + +async function loadCache(cacheFile: string, options: CliOptions): Promise { + try { + const raw = await fs.readFile(cacheFile, "utf8"); + const parsed = JSON.parse(raw) as CacheFile; + if (parsed.version !== 1 || parsed.queryKey !== buildQueryKey(options) || parsed.searchField !== options.searchField) { + return createEmptyCache(options); + } + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return createEmptyCache(options); + } + throw error; + } +} + +function createEmptyCache(options: CliOptions): CacheFile { + return { + commits: {}, + queryKey: buildQueryKey(options), + searchField: options.searchField, + stats: {}, + updatedAt: null, + version: 1, + windows: {}, + }; +} + +function buildQueryKey(options: CliOptions): string { + const visibility = options.includePrivate ? "all" : "public"; + return JSON.stringify({ + query: options.query, + searchField: options.searchField, + visibility, + }); +} + +async function saveCache(cacheFile: string, cache: CacheFile): Promise { + await fs.mkdir(path.dirname(cacheFile), { recursive: true }); + await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2), "utf8"); +} + +async function searchWindow( + client: GitHubClient, + cache: CacheFile, + options: CliOptions, + start: Date, + end: Date, +): Promise { + const windowKey = makeWindowKey(start, end); + if (!options.refreshSearch) { + const cached = cache.windows[windowKey]; + if (cached) { + return { shas: new Set(cached.shas), totalCount: cached.totalCount }; + } + } + + const firstPage = await searchPage(client, options, start, end, 1, 100); + if (firstPage.incomplete_results) { + throw new Error(`GitHub returned incomplete search results for window ${windowKey}`); + } + + if (firstPage.total_count > SEARCH_WINDOW_LIMIT) { + const durationMs = end.getTime() - start.getTime(); + if (durationMs <= MIN_WINDOW_MS) { + throw new Error( + `Search window ${windowKey} still has ${firstPage.total_count} results after splitting to ${durationMs}ms.`, + ); + } + + const midpoint = new Date(start.getTime() + Math.floor(durationMs / 2)); + const left = await searchWindow(client, cache, options, start, midpoint); + const right = await searchWindow(client, cache, options, new Date(midpoint.getTime() + 1), end); + const shas = new Set([...left.shas, ...right.shas]); + + cache.windows[windowKey] = { + completedAt: new Date().toISOString(), + key: windowKey, + shas: [...shas], + totalCount: shas.size, + }; + + return { shas, totalCount: shas.size }; + } + + const pageCount = Math.ceil(firstPage.total_count / 100); + const shas = new Set(); + ingestSearchItems(cache, firstPage.items, shas); + + for (let page = 2; page <= pageCount; page += 1) { + const response = await searchPage(client, options, start, end, page, 100); + if (response.incomplete_results) { + throw new Error(`GitHub returned incomplete search results for window ${windowKey} on page ${page}`); + } + ingestSearchItems(cache, response.items, shas); + } + + cache.windows[windowKey] = { + completedAt: new Date().toISOString(), + key: windowKey, + shas: [...shas], + totalCount: firstPage.total_count, + }; + + return { shas, totalCount: firstPage.total_count }; +} + +async function searchPage( + client: GitHubClient, + options: CliOptions, + start: Date, + end: Date, + page: number, + perPage: number, +): Promise { + const searchQuery = buildSearchQuery(options, start, end); + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage), + q: searchQuery, + }); + + return client.getJson(`/search/commits?${params.toString()}`); +} + +function buildSearchQuery(options: CliOptions, start: Date, end: Date): string { + const qualifiers = [`${options.searchField}:${formatQueryDate(start)}..${formatQueryDate(end)}`]; + if (!options.includePrivate) { + qualifiers.push("is:public"); + } + return `${options.query} ${qualifiers.join(" ")}`.trim(); +} + +function filterShas(cache: CacheFile, shas: string[], options: CliOptions): string[] { + if (options.excludeOwners.length === 0) { + return shas; + } + + const excludedOwners = new Set(options.excludeOwners); + return shas.filter((sha) => { + const commit = cache.commits[sha]; + if (!commit) { + return false; + } + return !excludedOwners.has(getRepoOwner(commit.repositoryFullName)); + }); +} + +function sortFilteredShas(cache: CacheFile, shas: string[]): string[] { + return [...shas].sort((leftSha, rightSha) => { + const left = cache.commits[leftSha]; + const right = cache.commits[rightSha]; + const leftTime = left?.committedAt ? Date.parse(left.committedAt) : 0; + const rightTime = right?.committedAt ? Date.parse(right.committedAt) : 0; + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + const repoCompare = (left?.repositoryFullName ?? "").localeCompare(right?.repositoryFullName ?? ""); + if (repoCompare !== 0) { + return repoCompare; + } + return leftSha.localeCompare(rightSha); + }); +} + +function formatQueryDate(value: Date): string { + return new Date(Math.floor(value.getTime() / 1000) * 1000).toISOString().replace(".000Z", "Z"); +} + +function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set) { + for (const item of items) { + shas.add(item.sha); + cache.commits[item.sha] = { + authorEmail: item.commit.author?.email ?? null, + authorLogin: item.author?.login ?? null, + authorName: item.commit.author?.name ?? null, + committedAt: item.commit.author?.date ?? null, + contributors: extractContributors(item), + htmlUrl: item.html_url, + repositoryFullName: item.repository.full_name, + repositoryUrl: item.repository.html_url, + sha: item.sha, + }; + } +} + +function extractContributors(item: SearchCommitItem): ContributorRecord[] { + const contributors = new Map(); + + const primaryAuthor = normalizeContributor({ + email: item.commit.author?.email ?? null, + login: item.author?.login ?? null, + name: item.commit.author?.name ?? null, + }); + if (primaryAuthor) { + contributors.set(primaryAuthor.key, primaryAuthor); + } + + const coAuthorPattern = /^co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim; + for (const match of item.commit.message.matchAll(coAuthorPattern)) { + const contributor = normalizeContributor({ + email: match[2] ?? null, + login: null, + name: match[1] ?? null, + }); + if (contributor) { + contributors.set(contributor.key, contributor); + } + } + + return [...contributors.values()]; +} + +function normalizeContributor(input: { + email: string | null; + login: string | null; + name: string | null; +}): ContributorRecord | null { + const email = normalizeOptional(input.email); + const login = normalizeOptional(input.login); + const displayName = normalizeOptional(input.name) ?? login ?? email; + + if (!displayName && !email && !login) { + return null; + } + if ((email && email === PAPERCLIP_EMAIL) || (displayName && displayName.toLowerCase() === PAPERCLIP_NAME)) { + return null; + } + + const key = login ? `login:${login}` : email ? `email:${email}` : `name:${displayName!.toLowerCase()}`; + return { + displayName: displayName ?? email ?? login ?? "unknown", + email, + key, + login, + }; +} + +function normalizeOptional(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +function getRepoOwner(repositoryFullName: string): string { + return repositoryFullName.split("/", 1)[0]?.toLowerCase() ?? ""; +} + +async function enrichCommitStats( + client: GitHubClient, + cache: CacheFile, + options: CliOptions, + shas: string[], +): Promise { + const pending = shas.filter((sha) => options.refreshStats || !cache.stats[sha]).slice(0, options.statsFetchLimit); + let nextIndex = 0; + let fetched = 0; + + const workers = Array.from({ length: Math.min(options.statsConcurrency, pending.length) }, async () => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + const sha = pending[currentIndex]; + if (!sha) { + return; + } + const commit = cache.commits[sha]; + if (!commit) { + continue; + } + const stats = await fetchCommitStats(client, commit.repositoryFullName, sha); + cache.stats[sha] = { + ...stats, + fetchedAt: new Date().toISOString(), + }; + fetched += 1; + } + }); + + await Promise.all(workers); + return fetched; +} + +async function fetchCommitStats(client: GitHubClient, repositoryFullName: string, sha: string): Promise { + const response = await client.getJson<{ stats?: CommitStats }>( + `/repos/${repositoryFullName}/commits/${sha}`, + ); + return { + additions: response.stats?.additions ?? 0, + deletions: response.stats?.deletions ?? 0, + total: response.stats?.total ?? 0, + }; +} + +function buildSummary(cache: CacheFile, options: CliOptions, shas: string[], fetchedThisRun: number): Summary { + const repoNames = new Set(); + const contributors = new Map(); + let additions = 0; + let deletions = 0; + let coveredCommits = 0; + + for (const sha of shas) { + const commit = cache.commits[sha]; + if (!commit) { + continue; + } + repoNames.add(commit.repositoryFullName); + for (const contributor of commit.contributors) { + contributors.set(contributor.key, contributor); + } + + const stats = cache.stats[sha]; + if (stats) { + additions += stats.additions; + deletions += stats.deletions; + coveredCommits += 1; + } + } + + const contributorSample = [...contributors.values()] + .sort((left, right) => left.displayName.localeCompare(right.displayName)) + .slice(0, 10); + const repoSample = [...repoNames].sort((left, right) => left.localeCompare(right)).slice(0, 10); + + return { + cacheFile: options.cacheFile, + contributors: { + count: contributors.size, + sample: contributorSample, + }, + detectedQuery: buildSearchQuery(options, options.start, options.end), + lineStats: { + additions, + complete: coveredCommits === shas.length, + coveredCommits, + deletions, + missingCommits: shas.length - coveredCommits, + totalChanges: additions + deletions, + }, + range: { + end: options.end.toISOString(), + searchField: options.searchField, + start: options.start.toISOString(), + }, + filters: { + excludedOwners: [...options.excludeOwners].sort(), + }, + repos: { + count: repoNames.size, + sample: repoSample, + }, + statsFetch: { + fetchedThisRun, + skipped: options.skipStats, + }, + totals: { + commits: shas.length, + }, + }; +} + +function printSummary(summary: Summary) { + console.log("Paperclip commit metrics"); + console.log(`Query: ${summary.detectedQuery}`); + console.log(`Range: ${summary.range.start} -> ${summary.range.end} (${summary.range.searchField})`); + if (summary.filters.excludedOwners.length > 0) { + console.log(`Excluded owners: ${summary.filters.excludedOwners.join(", ")}`); + } + console.log(`Commits: ${summary.totals.commits}`); + console.log(`Distinct repos: ${summary.repos.count}`); + console.log(`Distinct contributors: ${summary.contributors.count}`); + console.log( + `Line stats: +${summary.lineStats.additions} / -${summary.lineStats.deletions} / ${summary.lineStats.totalChanges} total`, + ); + console.log( + `Line stat coverage: ${summary.lineStats.coveredCommits}/${summary.totals.commits}` + + (summary.lineStats.complete ? " (complete)" : " (partial; rerun to hydrate more commits)"), + ); + console.log(`Stats fetched this run: ${summary.statsFetch.fetchedThisRun}${summary.statsFetch.skipped ? " (skipped)" : ""}`); + console.log(`Cache: ${summary.cacheFile}`); + + if (summary.repos.sample.length > 0) { + console.log(`Sample repos: ${summary.repos.sample.join(", ")}`); + } + if (summary.contributors.sample.length > 0) { + console.log( + `Sample contributors: ${summary.contributors.sample + .map((contributor) => contributor.login ?? contributor.displayName) + .join(", ")}`, + ); + } +} + +async function writeExport( + outputPath: string, + format: CliOptions["exportFormat"], + cache: CacheFile, + shas: string[], + summary: Summary, +): Promise { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + if (format === "json") { + const report = { + summary, + commits: shas.map((sha) => buildExportRow(cache, sha)), + }; + await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8"); + return; + } + + const header = [ + "committedAt", + "repository", + "repositoryUrl", + "sha", + "commitUrl", + "authorLogin", + "authorName", + "authorEmail", + "contributors", + "additions", + "deletions", + "totalChanges", + ]; + const rows = [header.join(",")]; + for (const sha of shas) { + const row = buildExportRow(cache, sha); + rows.push( + [ + row.committedAt, + row.repository, + row.repositoryUrl, + row.sha, + row.commitUrl, + row.authorLogin, + row.authorName, + row.authorEmail, + row.contributors, + String(row.additions), + String(row.deletions), + String(row.totalChanges), + ] + .map(escapeCsv) + .join(","), + ); + } + await fs.writeFile(outputPath, `${rows.join("\n")}\n`, "utf8"); +} + +function buildExportRow(cache: CacheFile, sha: string) { + const commit = cache.commits[sha]; + if (!commit) { + throw new Error(`Missing cached commit for sha ${sha}`); + } + const stats = cache.stats[sha]; + return { + additions: stats?.additions ?? 0, + authorEmail: commit.authorEmail ?? "", + authorLogin: commit.authorLogin ?? "", + authorName: commit.authorName ?? "", + commitUrl: commit.htmlUrl, + committedAt: commit.committedAt ?? "", + contributors: commit.contributors.map((contributor) => contributor.login ?? contributor.displayName).join(" | "), + deletions: stats?.deletions ?? 0, + repository: commit.repositoryFullName, + repositoryUrl: commit.repositoryUrl, + sha: commit.sha, + totalChanges: stats?.total ?? 0, + }; +} + +function escapeCsv(value: string): string { + if (value.includes(",") || value.includes("\"") || value.includes("\n")) { + return `"${value.replaceAll("\"", "\"\"")}"`; + } + return value; +} + +function makeWindowKey(start: Date, end: Date): string { + return `${start.toISOString()}..${end.toISOString()}`; +} + +class GitHubClient { + private readonly apiBase = "https://api.github.com"; + private readonly token: string; + + constructor(token: string) { + this.token = token; + } + + async getJson(pathname: string): Promise { + while (true) { + const response = await fetch(`${this.apiBase}${pathname}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.token}`, + "User-Agent": "paperclip-commit-metrics", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (response.ok) { + return (await response.json()) as T; + } + + const retryAfter = response.headers.get("retry-after"); + if ((response.status === 403 || response.status === 429) && retryAfter) { + const waitMs = Math.max(Number.parseInt(retryAfter, 10) * 1000, 1_000); + console.error(`GitHub secondary rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`); + await sleep(waitMs); + continue; + } + + const remaining = response.headers.get("x-ratelimit-remaining"); + const resetAt = response.headers.get("x-ratelimit-reset"); + if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) { + const waitMs = Math.max(Number.parseInt(resetAt, 10) * 1000 - Date.now() + 1_000, 1_000); + console.error(`GitHub rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`); + await sleep(waitMs); + continue; + } + + const body = await response.text(); + throw new Error(`GitHub API request failed (${response.status}) for ${pathname}: ${body}`); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 14a31349..19b0831e 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -3,6 +3,12 @@ set -euo pipefail base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}" worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" +paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}" +paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" +paperclip_dir="$worktree_cwd/.paperclip" +worktree_config_path="$paperclip_dir/config.json" +worktree_env_path="$paperclip_dir/.env" +worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}" if [[ ! -d "$base_cwd" ]]; then echo "Base workspace does not exist: $base_cwd" >&2 @@ -14,6 +20,286 @@ if [[ ! -d "$worktree_cwd" ]]; then exit 1 fi +source_config_path="${PAPERCLIP_CONFIG:-}" +if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then + source_config_path="$base_cwd/.paperclip/config.json" +fi +if [[ -z "$source_config_path" ]]; then + source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json" +fi +source_env_path="$(dirname "$source_config_path")/.env" + +mkdir -p "$paperclip_dir" + +run_isolated_worktree_init() { + if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then + pnpm paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + return 0 + fi + + if command -v paperclipai >/dev/null 2>&1; then + paperclipai worktree init --force --seed-mode minimal --name "$worktree_name" --from-config "$source_config_path" + return 0 + fi + + return 1 +} + +write_fallback_worktree_config() { + WORKTREE_NAME="$worktree_name" \ + BASE_CWD="$base_cwd" \ + WORKTREE_CWD="$worktree_cwd" \ + PAPERCLIP_DIR="$paperclip_dir" \ + SOURCE_CONFIG_PATH="$source_config_path" \ + SOURCE_ENV_PATH="$source_env_path" \ + PAPERCLIP_WORKTREES_DIR="${PAPERCLIP_WORKTREES_DIR:-}" \ + node <<'EOF' +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const net = require("node:net"); + +function expandHomePrefix(value) { + if (!value) return value; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function nonEmpty(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function sanitizeInstanceId(value) { + const trimmed = String(value ?? "").trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +function parseEnvFile(contents) { + const entries = {}; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + const [, key, rawValue] = match; + const value = rawValue.trim(); + if (!value) { + entries[key] = ""; + continue; + } + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + entries[key] = value.slice(1, -1); + continue; + } + entries[key] = value.replace(/\s+#.*$/, "").trim(); + } + return entries; +} + +async function findAvailablePort(preferredPort, reserved = new Set()) { + const startPort = Number.isFinite(preferredPort) && preferredPort > 0 ? Math.trunc(preferredPort) : 0; + if (startPort > 0) { + for (let port = startPort; port < startPort + 100; port += 1) { + if (reserved.has(port)) continue; + const available = await new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); + if (available) return port; + } + } + + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate a port."))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + +function isLoopbackHost(hostname) { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +function rewriteLocalUrlPort(rawUrl, port) { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } +} + +function resolveRuntimeLikePath(value, configPath) { + const expanded = expandHomePrefix(value); + if (path.isAbsolute(expanded)) return expanded; + return path.resolve(path.dirname(configPath), expanded); +} + +async function main() { + const worktreeName = process.env.WORKTREE_NAME; + const paperclipDir = process.env.PAPERCLIP_DIR; + const sourceConfigPath = process.env.SOURCE_CONFIG_PATH; + const sourceEnvPath = process.env.SOURCE_ENV_PATH; + const worktreeHome = path.resolve(expandHomePrefix(nonEmpty(process.env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees")); + const instanceId = sanitizeInstanceId(worktreeName); + const instanceRoot = path.resolve(worktreeHome, "instances", instanceId); + const configPath = path.resolve(paperclipDir, "config.json"); + const envPath = path.resolve(paperclipDir, ".env"); + + let sourceConfig = null; + if (sourceConfigPath && fs.existsSync(sourceConfigPath)) { + sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8")); + } + + const sourceEnvEntries = + sourceEnvPath && fs.existsSync(sourceEnvPath) + ? parseEnvFile(fs.readFileSync(sourceEnvPath, "utf8")) + : {}; + + const preferredServerPort = Number(sourceConfig?.server?.port ?? 3101) + 1; + const serverPort = await findAvailablePort(preferredServerPort); + const preferredDbPort = Number(sourceConfig?.database?.embeddedPostgresPort ?? 54329) + 1; + const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + + fs.rmSync(configPath, { force: true }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(instanceRoot, { recursive: true }); + + const authPublicBaseUrl = rewriteLocalUrlPort(sourceConfig?.auth?.publicBaseUrl, serverPort); + const targetConfig = { + $meta: { + version: 1, + updatedAt: new Date().toISOString(), + source: "configure", + }, + ...(sourceConfig?.llm ? { llm: sourceConfig.llm } : {}), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + embeddedPostgresPort: databasePort, + backup: { + enabled: sourceConfig?.database?.backup?.enabled ?? true, + intervalMinutes: sourceConfig?.database?.backup?.intervalMinutes ?? 60, + retentionDays: sourceConfig?.database?.backup?.retentionDays ?? 30, + dir: path.resolve(instanceRoot, "data", "backups"), + }, + }, + logging: { + mode: sourceConfig?.logging?.mode ?? "file", + logDir: path.resolve(instanceRoot, "logs"), + }, + server: { + deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted", + exposure: sourceConfig?.server?.exposure ?? "private", + host: sourceConfig?.server?.host ?? "127.0.0.1", + port: serverPort, + allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [], + serveUi: sourceConfig?.server?.serveUi ?? true, + }, + auth: { + baseUrlMode: sourceConfig?.auth?.baseUrlMode ?? "auto", + ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), + disableSignUp: sourceConfig?.auth?.disableSignUp ?? false, + }, + storage: { + provider: sourceConfig?.storage?.provider ?? "local_disk", + localDisk: { + baseDir: path.resolve(instanceRoot, "data", "storage"), + }, + s3: { + bucket: sourceConfig?.storage?.s3?.bucket ?? "paperclip", + region: sourceConfig?.storage?.s3?.region ?? "us-east-1", + endpoint: sourceConfig?.storage?.s3?.endpoint, + prefix: sourceConfig?.storage?.s3?.prefix ?? "", + forcePathStyle: sourceConfig?.storage?.s3?.forcePathStyle ?? false, + }, + }, + secrets: { + provider: sourceConfig?.secrets?.provider ?? "local_encrypted", + strictMode: sourceConfig?.secrets?.strictMode ?? false, + localEncrypted: { + keyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + }, + }, + }; + + fs.writeFileSync(configPath, `${JSON.stringify(targetConfig, null, 2)}\n`, { mode: 0o600 }); + + const inlineMasterKey = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY); + if (inlineMasterKey) { + fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); + fs.writeFileSync(targetConfig.secrets.localEncrypted.keyFilePath, inlineMasterKey, { + encoding: "utf8", + mode: 0o600, + }); + } else { + const sourceKeyFilePath = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) + ? resolveRuntimeLikePath(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE, sourceConfigPath) + : nonEmpty(sourceConfig?.secrets?.localEncrypted?.keyFilePath) + ? resolveRuntimeLikePath(sourceConfig.secrets.localEncrypted.keyFilePath, sourceConfigPath) + : null; + + if (sourceKeyFilePath && fs.existsSync(sourceKeyFilePath)) { + fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); + fs.copyFileSync(sourceKeyFilePath, targetConfig.secrets.localEncrypted.keyFilePath); + fs.chmodSync(targetConfig.secrets.localEncrypted.keyFilePath, 0o600); + } + } + + const envLines = [ + "PAPERCLIP_HOME=" + JSON.stringify(worktreeHome), + "PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId), + "PAPERCLIP_CONFIG=" + JSON.stringify(configPath), + "PAPERCLIP_CONTEXT=" + JSON.stringify(path.resolve(worktreeHome, "context.json")), + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=" + JSON.stringify(worktreeName), + ]; + + const agentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET); + if (agentJwtSecret) { + envLines.push("PAPERCLIP_AGENT_JWT_SECRET=" + JSON.stringify(agentJwtSecret)); + } + + fs.writeFileSync(envPath, `${envLines.join("\n")}\n`, { mode: 0o600 }); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); +EOF +} + +if ! run_isolated_worktree_init; then + echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2 + write_fallback_worktree_config +fi + while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue source_path="$base_cwd/$relative_path" diff --git a/server/package.json b/server/package.json index 843f9ca7..b2d17ad3 100644 --- a/server/package.json +++ b/server/package.json @@ -32,15 +32,16 @@ "skills" ], "scripts": { - "dev": "tsx src/index.ts", - "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "preflight:workspace-links": "tsx ../scripts/ensure-workspace-package-links.ts", + "dev": "pnpm run preflight:workspace-links && tsx src/index.ts", + "dev:watch": "pnpm run preflight:workspace-links && cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", - "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", + "build": "pnpm run preflight:workspace-links && tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", "postpack": "rm -rf ui-dist", "clean": "rm -rf dist", "start": "node dist/index.js", - "typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" + "typecheck": "pnpm run preflight:workspace-links && pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", @@ -65,7 +66,7 @@ "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", - "hermes-paperclip-adapter": "0.1.1", + "hermes-paperclip-adapter": "^0.2.0", "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", diff --git a/server/scripts/dev-watch.ts b/server/scripts/dev-watch.ts new file mode 100644 index 00000000..b3f944b8 --- /dev/null +++ b/server/scripts/dev-watch.ts @@ -0,0 +1,33 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts"; + +const require = createRequire(import.meta.url); +const tsxCliPath = require.resolve("tsx/cli"); +const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--exclude", ignorePath]); + +const child = spawn( + process.execPath, + [tsxCliPath, "watch", ...ignoreArgs, "src/index.ts"], + { + cwd: serverRoot, + env: process.env, + stdio: "inherit", + }, +); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); + +child.on("error", (error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index 7ec3f965..a6c5eb35 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local"; +import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local"; import { resetOpenCodeModelsCacheForTests } from "@paperclipai/adapter-opencode-local/server"; import { listAdapterModels } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; @@ -76,6 +77,14 @@ describe("adapter model listing", () => { expect(models).toEqual(cursorFallbackModels); }); + it("returns opencode fallback models including gpt-5.4", async () => { + process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; + + const models = await listAdapterModels("opencode_local"); + + expect(models).toEqual(opencodeFallbackModels); + }); + it("loads cursor models dynamically and caches them", async () => { const runner = vi.fn(() => ({ status: 0, @@ -95,10 +104,4 @@ describe("adapter model listing", () => { expect(first.some((model) => model.id === "composer-1")).toBe(true); }); - it("returns no opencode models when opencode command is unavailable", async () => { - process.env.PAPERCLIP_OPENCODE_COMMAND = "__paperclip_missing_opencode_command__"; - - const models = await listAdapterModels("opencode_local"); - expect(models).toEqual([]); - }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 08941f77..7bd79f76 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { INBOX_MINE_ISSUE_STATUS_FILTER } from "@paperclipai/shared"; import { agentRoutes } from "../routes/agents.js"; import { errorHandler } from "../middleware/index.js"; @@ -272,4 +273,42 @@ describe("agent permission routes", () => { expect(res.body.access.canAssignTasks).toBe(true); expect(res.body.access.taskAssignSource).toBe("agent_creator"); }); + + it("exposes a dedicated agent route for the inbox mine view", async () => { + mockIssueService.list.mockResolvedValue([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + + const app = createApp({ + type: "agent", + agentId, + companyId, + runId: "run-1", + source: "agent_key", + }); + + const res = await request(app) + .get("/api/agents/me/inbox/mine") + .query({ userId: "board-user" }); + + expect(res.status).toBe(200); + expect(mockIssueService.list).toHaveBeenCalledWith(companyId, { + touchedByUserId: "board-user", + inboxArchivedByUserId: "board-user", + status: INBOX_MINE_ISSUE_STATUS_FILTER, + }); + expect(res.body).toEqual([ + { + id: "issue-1", + identifier: "PAP-910", + title: "Inbox follow-up", + status: "todo", + }, + ]); + }); }); diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index 62e1e68e..9a4789b2 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -84,6 +84,28 @@ describe("boardMutationGuard", () => { expect(res.status).toBe(204); }); + it("allows board mutations when x-forwarded-host matches origin", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Host", "127.0.0.1") + .set("X-Forwarded-Host", "10.90.10.20:3443") + .set("Origin", "https://10.90.10.20:3443") + .send({ ok: true }); + expect(res.status).toBe(204); + }); + + it("blocks board mutations when x-forwarded-host does not match origin", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Host", "127.0.0.1") + .set("X-Forwarded-Host", "10.90.10.20:3443") + .set("Origin", "https://evil.example.com") + .send({ ok: true }); + expect(res.status).toBe(403); + }); + it("does not block authenticated agent mutations", async () => { const middleware = boardMutationGuard(); const req = { diff --git a/server/src/__tests__/claude-local-adapter.test.ts b/server/src/__tests__/claude-local-adapter.test.ts index 26fc4531..d2cb8665 100644 --- a/server/src/__tests__/claude-local-adapter.test.ts +++ b/server/src/__tests__/claude-local-adapter.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server"; +import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui"; +import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; describe("claude_local max-turn detection", () => { it("detects max-turn exhaustion by subtype", () => { @@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => { ).toBe(false); }); }); + +describe("claude_local ui stdout parser", () => { + it("maps assistant text, thinking, tool calls, and tool results into transcript entries", () => { + const ts = "2026-03-29T00:00:00.000Z"; + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-sonnet-4-6", + session_id: "claude-session-1", + }), + ts, + ), + ).toEqual([ + { + kind: "init", + ts, + model: "claude-sonnet-4-6", + sessionId: "claude-session-1", + }, + ]); + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "assistant", + session_id: "claude-session-1", + message: { + content: [ + { type: "text", text: "I will inspect the repo." }, + { type: "thinking", thinking: "Checking the adapter wiring" }, + { type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } }, + ], + }, + }), + ts, + ), + ).toEqual([ + { kind: "assistant", ts, text: "I will inspect the repo." }, + { kind: "thinking", ts, text: "Checking the adapter wiring" }, + { kind: "tool_call", ts, name: "bash", toolUseId: "tool_1", input: { command: "ls -1" } }, + ]); + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: [{ type: "text", text: "AGENTS.md\nREADME.md" }], + is_error: false, + }, + ], + }, + }), + ts, + ), + ).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tool_1", + content: "AGENTS.md\nREADME.md", + isError: false, + }, + ]); + }); +}); + +function stripAnsi(value: string) { + return value.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("claude_local cli formatter", () => { + it("prints the user-visible and background transcript events from stream-json output", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + printClaudeStreamEvent( + JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-sonnet-4-6", + session_id: "claude-session-1", + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "assistant", + message: { + content: [ + { type: "text", text: "I will inspect the repo." }, + { type: "thinking", thinking: "Checking the adapter wiring" }, + { type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } }, + ], + }, + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: [{ type: "text", text: "AGENTS.md\nREADME.md" }], + is_error: false, + }, + ], + }, + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "result", + subtype: "success", + result: "Done", + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 }, + total_cost_usd: 0.00042, + }), + false, + ); + + const lines = spy.mock.calls + .map((call) => call.map((value) => String(value)).join(" ")) + .map(stripAnsi); + + expect(lines).toEqual( + expect.arrayContaining([ + "Claude initialized (model: claude-sonnet-4-6, session: claude-session-1)", + "assistant: I will inspect the repo.", + "thinking: Checking the adapter wiring", + "tool_call: bash", + '{\n "command": "ls -1"\n}', + "tool_result", + "AGENTS.md\nREADME.md", + "result:", + "Done", + "tokens: in=10 out=5 cached=2 cost=$0.000420", + ]), + ); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts new file mode 100644 index 00000000..be933bca --- /dev/null +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execute } from "@paperclipai/adapter-claude-local/server"; + +async function writeFakeClaudeCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); + +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const payload = { + argv: process.argv.slice(2), + prompt: fs.readFileSync(0, "utf8"), + claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, +}; +if (capturePath) { + fs.writeFileSync(capturePath, JSON.stringify(payload), "utf8"); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-1", model: "claude-sonnet" })); +console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-1", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +describe("claude execute", () => { + it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-")); + const workspace = path.join(root, "workspace"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "claude"); + const capturePath = path.join(root, "capture.json"); + const claudeConfigDir = path.join(root, "claude-config"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await fs.mkdir(claudeConfigDir, { recursive: true }); + await writeFakeClaudeCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + const previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + + let loggedCommand: string | null = null; + let loggedEnv: Record = {}; + try { + const result = await execute({ + runId: "run-meta", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "claude", + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + loggedCommand = meta.command; + loggedEnv = meta.env ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(loggedCommand).toBe(commandPath); + expect(loggedEnv.HOME).toBe(root); + expect(loggedEnv.CLAUDE_CONFIG_DIR).toBe(claudeConfigDir); + expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + await fs.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index b83e3db7..4f584435 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -195,6 +195,70 @@ describe("codex execute", () => { } }); + it("logs HOME and the resolved executable path in invocation metadata", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-meta-")); + const workspace = path.join(root, "workspace"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "codex"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + + let loggedCommand: string | null = null; + let loggedEnv: Record = {}; + try { + const result = await execute({ + runId: "run-meta", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: "codex", + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + loggedCommand = meta.command; + loggedEnv = meta.env ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(loggedCommand).toBe(commandPath); + expect(loggedEnv.HOME).toBe(root); + expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(commandPath); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/dev-watch-ignore.test.ts b/server/src/__tests__/dev-watch-ignore.test.ts new file mode 100644 index 00000000..4f54e609 --- /dev/null +++ b/server/src/__tests__/dev-watch-ignore.test.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveServerDevWatchIgnorePaths } from "../dev-watch-ignore.js"; + +describe("resolveServerDevWatchIgnorePaths", () => { + it("includes both the worktree UI paths and their real shared targets", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-watch-")); + const sharedUiRoot = path.join(tempRoot, "shared-ui"); + const worktreeRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees", "PAP-884"); + const serverRoot = path.join(worktreeRoot, "server"); + const worktreeUiRoot = path.join(worktreeRoot, "ui"); + + fs.mkdirSync(path.join(sharedUiRoot, "node_modules"), { recursive: true }); + fs.mkdirSync(path.join(sharedUiRoot, ".vite"), { recursive: true }); + fs.mkdirSync(path.join(sharedUiRoot, "dist"), { recursive: true }); + fs.mkdirSync(serverRoot, { recursive: true }); + fs.mkdirSync(worktreeUiRoot, { recursive: true }); + + fs.symlinkSync(path.join(sharedUiRoot, "node_modules"), path.join(worktreeUiRoot, "node_modules")); + fs.symlinkSync(path.join(sharedUiRoot, ".vite"), path.join(worktreeUiRoot, ".vite")); + fs.symlinkSync(path.join(sharedUiRoot, "dist"), path.join(worktreeUiRoot, "dist")); + + const ignorePaths = resolveServerDevWatchIgnorePaths(serverRoot); + + expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules")); + expect(ignorePaths).toContain(`${path.join(worktreeUiRoot, "node_modules").replaceAll(path.sep, "/")}/**`); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "node_modules"))); + expect(ignorePaths).toContain(`${fs.realpathSync(path.join(sharedUiRoot, "node_modules")).replaceAll(path.sep, "/")}/**`); + expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules", ".vite-temp")); + expect(ignorePaths).toContain( + `${path.join(worktreeUiRoot, "node_modules", ".vite-temp").replaceAll(path.sep, "/")}/**`, + ); + expect(ignorePaths).toContain(path.join(worktreeUiRoot, ".vite")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite"))); + expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist"))); + expect(ignorePaths).toContain("**/{node_modules,bower_components,vendor}/**"); + expect(ignorePaths).toContain("**/.vite-temp/**"); + }); +}); diff --git a/server/src/__tests__/execution-workspaces-service.test.ts b/server/src/__tests__/execution-workspaces-service.test.ts new file mode 100644 index 00000000..d4a50bdc --- /dev/null +++ b/server/src/__tests__/execution-workspaces-service.test.ts @@ -0,0 +1,325 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { promisify } from "node:util"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + companies, + createDb, + executionWorkspaces, + issues, + projectWorkspaces, + projects, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { + executionWorkspaceService, + mergeExecutionWorkspaceConfig, + readExecutionWorkspaceConfig, +} from "../services/execution-workspaces.ts"; + +const execFileAsync = promisify(execFile); + +describe("execution workspace config helpers", () => { + it("reads typed config from persisted metadata", () => { + expect(readExecutionWorkspaceConfig({ + source: "project_primary", + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev", port: 3100 }], + }, + }, + })).toEqual({ + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + desiredState: null, + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev", port: 3100 }], + }, + }); + }); + + it("merges config patches without dropping unrelated metadata", () => { + expect(mergeExecutionWorkspaceConfig( + { + source: "project_primary", + createdByRuntime: false, + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + cleanupCommand: "pkill -f vite || true", + }, + }, + { + teardownCommand: "bash ./scripts/teardown-worktree.sh", + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev" }], + }, + }, + )).toEqual({ + source: "project_primary", + createdByRuntime: false, + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + teardownCommand: "bash ./scripts/teardown-worktree.sh", + cleanupCommand: "pkill -f vite || true", + desiredState: null, + workspaceRuntime: { + services: [{ name: "web", command: "pnpm dev" }], + }, + }, + }); + }); + + it("clears the nested config block when requested", () => { + expect(mergeExecutionWorkspaceConfig( + { + source: "project_primary", + config: { + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + null, + )).toEqual({ + source: "project_primary", + }); + }); +}); + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres execution workspace service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function createTempRepo() { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-execution-workspace-")); + await runGit(repoRoot, ["init"]); + await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]); + await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]); + await fs.writeFile(path.join(repoRoot, "README.md"), "# Test repo\n", "utf8"); + await runGit(repoRoot, ["add", "README.md"]); + await runGit(repoRoot, ["commit", "-m", "Initial commit"]); + await runGit(repoRoot, ["branch", "-M", "main"]); + return repoRoot; +} + +describeEmbeddedPostgres("executionWorkspaceService.getCloseReadiness", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + const tempDirs = new Set(); + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-execution-workspaces-service-"); + db = createDb(tempDb.connectionString); + svc = executionWorkspaceService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(companies); + + for (const dir of tempDirs) { + await fs.rm(dir, { recursive: true, force: true }); + } + tempDirs.clear(); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("allows archiving shared workspace sessions with warnings even when issues are still open", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "local_path", + isPrimary: true, + cwd: "/tmp/paperclip-primary", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Shared workspace", + status: "active", + providerType: "local_fs", + cwd: "/tmp/paperclip-primary", + metadata: { + config: { + teardownCommand: "bash ./scripts/teardown.sh", + }, + }, + }); + await db.insert(issues).values({ + id: randomUUID(), + companyId, + projectId, + title: "Still working", + status: "todo", + priority: "medium", + executionWorkspaceId, + }); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ + workspaceId: executionWorkspaceId, + state: "ready_with_warnings", + isSharedWorkspace: true, + isProjectPrimaryWorkspace: true, + isDestructiveCloseAllowed: true, + }); + expect(readiness?.blockingReasons).toEqual([]); + expect(readiness?.warnings).toEqual(expect.arrayContaining([ + "This workspace is still linked to an open issue. Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.", + "This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record.", + ])); + }); + + it("warns about dirty and unmerged git worktrees and reports cleanup actions", async () => { + const repoRoot = await createTempRepo(); + tempDirs.add(repoRoot); + const worktreePath = path.join(path.dirname(repoRoot), `paperclip-worktree-${randomUUID()}`); + tempDirs.add(worktreePath); + + await runGit(repoRoot, ["branch", "paperclip-close-check"]); + await runGit(repoRoot, ["worktree", "add", worktreePath, "paperclip-close-check"]); + await fs.writeFile(path.join(worktreePath, "feature.txt"), "hello\n", "utf8"); + await runGit(worktreePath, ["add", "feature.txt"]); + await runGit(worktreePath, ["commit", "-m", "Feature commit"]); + await fs.writeFile(path.join(worktreePath, "untracked.txt"), "left behind\n", "utf8"); + + const companyId = randomUUID(); + const projectId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspaces", + status: "in_progress", + executionWorkspacePolicy: { + enabled: true, + workspaceStrategy: { + type: "git_worktree", + teardownCommand: "bash ./scripts/project-teardown.sh", + }, + }, + }); + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary", + sourceType: "git_repo", + isPrimary: true, + cwd: repoRoot, + cleanupCommand: "printf 'project cleanup\\n'", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Feature workspace", + status: "active", + providerType: "git_worktree", + cwd: worktreePath, + providerRef: worktreePath, + branchName: "paperclip-close-check", + baseRef: "main", + metadata: { + createdByRuntime: true, + config: { + cleanupCommand: "printf 'workspace cleanup\\n'", + }, + }, + }); + + const readiness = await svc.getCloseReadiness(executionWorkspaceId); + + expect(readiness).toMatchObject({ + workspaceId: executionWorkspaceId, + state: "ready_with_warnings", + isSharedWorkspace: false, + isProjectPrimaryWorkspace: false, + isDestructiveCloseAllowed: true, + git: { + workspacePath: worktreePath, + branchName: "paperclip-close-check", + baseRef: "main", + createdByRuntime: true, + hasDirtyTrackedFiles: false, + hasUntrackedFiles: true, + aheadCount: 1, + behindCount: 0, + isMergedIntoBase: false, + }, + }); + expect(readiness?.warnings).toEqual(expect.arrayContaining([ + "The workspace has 1 untracked file.", + "This workspace is 1 commit ahead of main and is not merged.", + ])); + expect(readiness?.plannedActions.map((action) => action.kind)).toEqual(expect.arrayContaining([ + "archive_record", + "cleanup_command", + "teardown_command", + "git_worktree_remove", + "git_branch_delete", + ])); + }, 20_000); +}); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 1511b95e..8f80eec6 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -1,16 +1,56 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import express from "express"; import request from "supertest"; +import type { Db } from "@paperclipai/db"; import { healthRoutes } from "../routes/health.js"; +import * as devServerStatus from "../dev-server-status.js"; import { serverVersion } from "../version.js"; describe("GET /health", () => { - const app = express(); - app.use("/health", healthRoutes()); + beforeEach(() => { + vi.spyOn(devServerStatus, "readPersistedDevServerStatus").mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); it("returns 200 with status ok", async () => { + const app = express(); + app.use("/health", healthRoutes()); + const res = await request(app).get("/health"); expect(res.status).toBe(200); expect(res.body).toEqual({ status: "ok", version: serverVersion }); }); + + it("returns 200 when the database probe succeeds", async () => { + const db = { + execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]), + } as unknown as Db; + const app = express(); + app.use("/health", healthRoutes(db)); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); + }); + + it("returns 503 when the database probe fails", async () => { + const db = { + execute: vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED")), + } as unknown as Db; + const app = express(); + app.use("/health", healthRoutes(db)); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(503); + expect(res.body).toEqual({ + status: "unhealthy", + version: serverVersion, + error: "database_unreachable", + }); + }); }); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index d0e3cc31..6b18d162 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,89 +1,29 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { - applyPendingMigrations, - createDb, - ensurePostgresDatabase, agents, agentWakeupRequests, companies, + createDb, heartbeatRunEvents, heartbeatRuns, issues, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { runningProcesses } from "../adapters/index.ts"; import { heartbeatService } from "../services/heartbeat.ts"; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, instance, dataDir }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function spawnAliveProcess() { @@ -92,17 +32,14 @@ function spawnAliveProcess() { }); } -describe("heartbeat orphaned process recovery", () => { +describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; const childProcesses = new Set(); beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -125,10 +62,7 @@ describe("heartbeat orphaned process recovery", () => { } childProcesses.clear(); runningProcesses.clear(); - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedRunFixture(input?: { diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 7fab2b42..5a1498f2 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -3,11 +3,14 @@ import type { agents } from "@paperclipai/db"; import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + applyPersistedExecutionWorkspaceConfig, + buildRealizedExecutionWorkspaceFromPersisted, buildExplicitResumeSessionOverride, formatRuntimeWorkspaceWarningLog, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, + stripWorkspaceRuntimeFromExecutionRunConfig, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, } from "../services/heartbeat.ts"; @@ -120,6 +123,147 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => { }); }); +describe("applyPersistedExecutionWorkspaceConfig", () => { + it("does not add workspace runtime when only the project workspace had manual runtime config", () => { + const result = applyPersistedExecutionWorkspaceConfig({ + config: {}, + workspaceConfig: null, + mode: "isolated_workspace", + }); + + expect("workspaceRuntime" in result).toBe(false); + }); + + it("applies explicit persisted execution workspace runtime config when present", () => { + const result = applyPersistedExecutionWorkspaceConfig({ + config: {}, + workspaceConfig: { + provisionCommand: null, + teardownCommand: null, + cleanupCommand: null, + desiredState: null, + workspaceRuntime: { + services: [{ name: "workspace-web" }], + }, + }, + mode: "isolated_workspace", + }); + + expect(result.workspaceRuntime).toEqual({ + services: [{ name: "workspace-web" }], + }); + }); +}); + +describe("buildRealizedExecutionWorkspaceFromPersisted", () => { + it("reuses the persisted execution workspace path instead of deriving a new worktree", () => { + const result = buildRealizedExecutionWorkspaceFromPersisted({ + base: buildResolvedWorkspace({ + cwd: "/tmp/project-primary", + repoRef: "main", + }), + workspace: { + id: "execution-workspace-1", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + sourceIssueId: "issue-1", + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-880-thumbs-capture-for-evals-feature", + status: "active", + cwd: "/tmp/reused-worktree", + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "PAP-880-thumbs-capture-for-evals-feature", + providerType: "git_worktree", + providerRef: "/tmp/reused-worktree", + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date(), + openedAt: new Date(), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + expect(result.created).toBe(false); + expect(result.strategy).toBe("git_worktree"); + expect(result.cwd).toBe("/tmp/reused-worktree"); + expect(result.worktreePath).toBe("/tmp/reused-worktree"); + expect(result.branchName).toBe("PAP-880-thumbs-capture-for-evals-feature"); + expect(result.source).toBe("task_session"); + }); + + it("falls back to realization when the persisted workspace has no local path yet", () => { + const result = buildRealizedExecutionWorkspaceFromPersisted({ + base: buildResolvedWorkspace({ + cwd: "/tmp/project-primary", + repoRef: "main", + }), + workspace: { + id: "execution-workspace-2", + companyId: "company-1", + projectId: "project-1", + projectWorkspaceId: "workspace-1", + sourceIssueId: "issue-2", + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "PAP-999-missing-provider-ref", + status: "active", + cwd: null, + repoUrl: "https://example.com/paperclip.git", + baseRef: "main", + branchName: "feature/PAP-999-missing-provider-ref", + providerType: "git_worktree", + providerRef: null, + derivedFromExecutionWorkspaceId: null, + lastUsedAt: new Date(), + openedAt: new Date(), + closedAt: null, + cleanupEligibleAt: null, + cleanupReason: null, + config: null, + metadata: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + expect(result).toBeNull(); + }); +}); + +describe("stripWorkspaceRuntimeFromExecutionRunConfig", () => { + it("removes workspace runtime before heartbeat execution", () => { + const input = { + cwd: "/tmp/project", + workspaceStrategy: { + type: "git_worktree", + }, + workspaceRuntime: { + services: [{ name: "web" }], + }, + }; + + const result = stripWorkspaceRuntimeFromExecutionRunConfig(input); + + expect(result).toEqual({ + cwd: "/tmp/project", + workspaceStrategy: { + type: "git_worktree", + }, + }); + expect(input.workspaceRuntime).toEqual({ + services: [{ name: "web" }], + }); + }); +}); + describe("shouldResetTaskSessionForWake", () => { it("resets session context on assignment wake", () => { expect(shouldResetTaskSessionForWake({ wakeReason: "issue_assigned" })).toBe(true); diff --git a/server/src/__tests__/helpers/embedded-postgres.ts b/server/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..4318162a --- /dev/null +++ b/server/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,6 @@ +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "@paperclipai/db"; diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 42c4cb0d..21bb44aa 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -19,6 +19,9 @@ const mockAccessService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn(async () => undefined), reportRunActivity: vi.fn(async () => undefined), + getRun: vi.fn(async () => null), + getActiveRunForAgent: vi.fn(async () => null), + cancelRun: vi.fn(async () => null), })); const mockAgentService = vi.hoisted(() => ({ @@ -143,4 +146,46 @@ describe("issue comment reopen routes", () => { }), ); }); + + it("interrupts an active run before a combined comment update", async () => { + const issue = { + ...makeIssue("todo"), + executionRunId: "run-1", + }; + mockIssueService.getById.mockResolvedValue(issue); + mockIssueService.update.mockImplementation(async (_id: string, patch: Record) => ({ + ...issue, + ...patch, + })); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "running", + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: "company-1", + agentId: "22222222-2222-4222-8222-222222222222", + status: "cancelled", + }); + + const res = await request(createApp()) + .patch("/api/issues/11111111-1111-4111-8111-111111111111") + .send({ comment: "hello", interrupt: true, assigneeAgentId: "33333333-3333-4333-8333-333333333333" }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); + expect(mockLogActivity).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + action: "heartbeat.cancelled", + details: expect.objectContaining({ + source: "issue_comment_interrupt", + issueId: "11111111-1111-4111-8111-111111111111", + }), + }), + ); + }); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ba27866f..9543a614 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1,114 +1,60 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, createDb, - ensurePostgresDatabase, + executionWorkspaces, + instanceSettings, issueComments, + issueInboxArchives, issues, + projectWorkspaces, + projects, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("issueService.list participantAgentId", () => { +describeEmbeddedPostgres("issueService.list participantAgentId", () => { let db!: ReturnType; let svc!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-"); + db = createDb(tempDb.connectionString); svc = issueService(db); - instance = started.instance; - dataDir = started.dataDir; }, 20_000); afterEach(async () => { await db.delete(issueComments); + await db.delete(issueInboxArchives); await db.delete(activityLog); await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); await db.delete(agents); + await db.delete(instanceSettings); await db.delete(companies); }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); it("returns issues an agent participated in across the supported signals", async () => { @@ -281,4 +227,454 @@ describe("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); }); + + it("filters issues by execution workspace id", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const targetWorkspaceId = randomUUID(); + const otherWorkspaceId = randomUUID(); + const linkedIssueId = randomUUID(); + const otherLinkedIssueId = randomUUID(); + const unlinkedIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(executionWorkspaces).values([ + { + id: targetWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Target workspace", + status: "active", + providerType: "local_fs", + }, + { + id: otherWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Other workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values([ + { + id: linkedIssueId, + companyId, + projectId, + title: "Linked issue", + status: "todo", + priority: "medium", + executionWorkspaceId: targetWorkspaceId, + }, + { + id: otherLinkedIssueId, + companyId, + projectId, + title: "Other linked issue", + status: "todo", + priority: "medium", + executionWorkspaceId: otherWorkspaceId, + }, + { + id: unlinkedIssueId, + companyId, + projectId, + title: "Unlinked issue", + status: "todo", + priority: "medium", + }, + ]); + + const result = await svc.list(companyId, { executionWorkspaceId: targetWorkspaceId }); + + expect(result.map((issue) => issue.id)).toEqual([linkedIssueId]); + }); + + it("hides archived inbox issues until new external activity arrives", async () => { + const companyId = randomUUID(); + const userId = "user-1"; + const otherUserId = "user-2"; + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const visibleIssueId = randomUUID(); + const archivedIssueId = randomUUID(); + const resurfacedIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: visibleIssueId, + companyId, + title: "Visible issue", + status: "todo", + priority: "medium", + createdByUserId: userId, + createdAt: new Date("2026-03-26T10:00:00.000Z"), + updatedAt: new Date("2026-03-26T10:00:00.000Z"), + }, + { + id: archivedIssueId, + companyId, + title: "Archived issue", + status: "todo", + priority: "medium", + createdByUserId: userId, + createdAt: new Date("2026-03-26T11:00:00.000Z"), + updatedAt: new Date("2026-03-26T11:00:00.000Z"), + }, + { + id: resurfacedIssueId, + companyId, + title: "Resurfaced issue", + status: "todo", + priority: "medium", + createdByUserId: userId, + createdAt: new Date("2026-03-26T12:00:00.000Z"), + updatedAt: new Date("2026-03-26T12:00:00.000Z"), + }, + ]); + + await svc.archiveInbox( + companyId, + archivedIssueId, + userId, + new Date("2026-03-26T12:30:00.000Z"), + ); + await svc.archiveInbox( + companyId, + resurfacedIssueId, + userId, + new Date("2026-03-26T13:00:00.000Z"), + ); + + await db.insert(issueComments).values({ + companyId, + issueId: resurfacedIssueId, + authorUserId: otherUserId, + body: "This should bring the issue back into Mine.", + createdAt: new Date("2026-03-26T13:30:00.000Z"), + updatedAt: new Date("2026-03-26T13:30:00.000Z"), + }); + + const archivedFiltered = await svc.list(companyId, { + touchedByUserId: userId, + inboxArchivedByUserId: userId, + }); + + expect(archivedFiltered.map((issue) => issue.id)).toEqual([ + resurfacedIssueId, + visibleIssueId, + ]); + + await svc.unarchiveInbox(companyId, archivedIssueId, userId); + + const afterUnarchive = await svc.list(companyId, { + touchedByUserId: userId, + inboxArchivedByUserId: userId, + }); + + expect(new Set(afterUnarchive.map((issue) => issue.id))).toEqual(new Set([ + visibleIssueId, + archivedIssueId, + resurfacedIssueId, + ])); + }); +}); + +describeEmbeddedPostgres("issueService.create workspace inheritance", () => { + let db!: ReturnType; + let svc!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-create-"); + db = createDb(tempDb.connectionString); + svc = issueService(db); + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(issueInboxArchives); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(executionWorkspaces); + await db.delete(projectWorkspaces); + await db.delete(projects); + await db.delete(agents); + await db.delete(instanceSettings); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("inherits the parent issue workspace linkage when child workspace fields are omitted", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + isPrimary: true, + sharedWorkspaceKey: "workspace-key", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + providerType: "git_worktree", + providerRef: `/tmp/${executionWorkspaceId}`, + }); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + }); + + expect(child.parentId).toBe(parentIssueId); + expect(child.projectWorkspaceId).toBe(projectWorkspaceId); + expect(child.executionWorkspaceId).toBe(executionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "isolated_workspace", + workspaceRuntime: { profile: "agent" }, + }); + }); + + it("keeps explicit workspace fields instead of inheriting the parent linkage", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const parentIssueId = randomUUID(); + const parentProjectWorkspaceId = randomUUID(); + const parentExecutionWorkspaceId = randomUUID(); + const explicitProjectWorkspaceId = randomUUID(); + const explicitExecutionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values([ + { + id: parentProjectWorkspaceId, + companyId, + projectId, + name: "Parent workspace", + }, + { + id: explicitProjectWorkspaceId, + companyId, + projectId, + name: "Explicit workspace", + }, + ]); + + await db.insert(executionWorkspaces).values([ + { + id: parentExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Parent worktree", + status: "active", + providerType: "git_worktree", + }, + { + id: explicitExecutionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: explicitProjectWorkspaceId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Explicit shared workspace", + status: "active", + providerType: "local_fs", + }, + ]); + + await db.insert(issues).values({ + id: parentIssueId, + companyId, + projectId, + projectWorkspaceId: parentProjectWorkspaceId, + title: "Parent issue", + status: "in_progress", + priority: "medium", + executionWorkspaceId: parentExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "isolated_workspace", + }, + }); + + const child = await svc.create(companyId, { + parentId: parentIssueId, + projectId, + title: "Child issue", + projectWorkspaceId: explicitProjectWorkspaceId, + executionWorkspaceId: explicitExecutionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "shared_workspace", + }, + }); + + expect(child.projectWorkspaceId).toBe(explicitProjectWorkspaceId); + expect(child.executionWorkspaceId).toBe(explicitExecutionWorkspaceId); + expect(child.executionWorkspacePreference).toBe("reuse_existing"); + expect(child.executionWorkspaceSettings).toEqual({ + mode: "shared_workspace", + }); + }); + + it("inherits workspace linkage from an explicit source issue without creating a parent-child relationship", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + const sourceIssueId = randomUUID(); + const projectWorkspaceId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); + + await db.insert(projectWorkspaces).values({ + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + }); + + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + mode: "operator_branch", + strategyType: "git_worktree", + name: "Operator branch", + status: "active", + providerType: "git_worktree", + }); + + await db.insert(issues).values({ + id: sourceIssueId, + companyId, + projectId, + projectWorkspaceId, + title: "Source issue", + status: "todo", + priority: "medium", + executionWorkspaceId, + executionWorkspacePreference: "reuse_existing", + executionWorkspaceSettings: { + mode: "operator_branch", + }, + }); + + const followUp = await svc.create(companyId, { + projectId, + title: "Follow-up issue", + inheritExecutionWorkspaceFromIssueId: sourceIssueId, + }); + + expect(followUp.parentId).toBeNull(); + expect(followUp.projectWorkspaceId).toBe(projectWorkspaceId); + expect(followUp.executionWorkspaceId).toBe(executionWorkspaceId); + expect(followUp.executionWorkspacePreference).toBe("reuse_existing"); + expect(followUp.executionWorkspaceSettings).toEqual({ + mode: "operator_branch", + }); + }); }); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index 189126f9..990587d3 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -1,6 +1,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { companies, invites } from "@paperclipai/db"; import { accessRoutes } from "../routes/access.js"; import { errorHandler } from "../middleware/index.js"; @@ -51,19 +52,35 @@ function createDbStub() { inviteType: "company_join", allowedJoinTypes: "agent", defaultsPayload: null, - expiresAt: new Date("2026-03-07T00:10:00.000Z"), + expiresAt: new Date("2099-03-07T00:10:00.000Z"), invitedByUserId: null, tokenHash: "hash", revokedAt: null, acceptedAt: null, - createdAt: new Date("2026-03-07T00:00:00.000Z"), - updatedAt: new Date("2026-03-07T00:00:00.000Z"), + createdAt: new Date("2099-03-07T00:00:00.000Z"), + updatedAt: new Date("2099-03-07T00:00:00.000Z"), }; const returning = vi.fn().mockResolvedValue([createdInvite]); const values = vi.fn().mockReturnValue({ returning }); const insert = vi.fn().mockReturnValue({ values }); + const select = vi.fn(() => ({ + from(table: unknown) { + return { + where: vi.fn().mockImplementation(() => { + if (table === invites) { + return Promise.resolve([createdInvite]); + } + if (table === companies) { + return Promise.resolve([{ name: "Acme AI" }]); + } + return Promise.resolve([]); + }), + }; + }, + })); return { insert, + select, }; } @@ -143,9 +160,30 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { expect(res.status).toBe(201); expect(res.body.allowedJoinTypes).toBe("agent"); expect(typeof res.body.token).toBe("string"); + expect(res.body.companyName).toBe("Acme AI"); expect(res.body.onboardingTextPath).toContain("/api/invites/"); }); + it("includes companyName in invite summary responses", async () => { + const db = createDbStub(); + const app = createApp( + { + type: "board", + userId: "user-1", + companyIds: ["company-1"], + source: "session", + isInstanceAdmin: false, + }, + db, + ); + + const res = await request(app).get("/api/invites/pcp_invite_test"); + + expect(res.status).toBe(200); + expect(res.body.companyId).toBe("company-1"); + expect(res.body.companyName).toBe("Acme AI"); + }); + it("allows board callers with invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(true); diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 83689724..ab5fd778 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -1,8 +1,4 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import express from "express"; import request from "supertest"; @@ -11,11 +7,9 @@ import { activityLog, agentWakeupRequests, agents, - applyPendingMigrations, companies, companyMemberships, createDb, - ensurePostgresDatabase, heartbeatRunEvents, heartbeatRuns, instanceSettings, @@ -26,6 +20,10 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; @@ -78,82 +76,22 @@ vi.mock("../services/index.js", async () => { }; }); -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("routine routes end-to-end", () => { +describeEmbeddedPostgres("routine routes end-to-end", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -174,10 +112,7 @@ describe("routine routes end-to-end", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function createApp(actor: Record) { diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index d5954246..d6aad0f2 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -1,19 +1,13 @@ import { createHmac, randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, companySecrets, companySecretVersions, createDb, - ensurePostgresDatabase, heartbeatRuns, issues, projects, @@ -21,85 +15,29 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.ts"; import { routineService } from "../services/routines.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("routine service live-execution coalescing", () => { +describeEmbeddedPostgres("routine service live-execution coalescing", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -117,10 +55,7 @@ describe("routine service live-execution coalescing", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedFixture(opts?: { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index dad02d38..6c98b2cf 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1,23 +1,51 @@ import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + createDb, + executionWorkspaces, + heartbeatRuns, + projects, + workspaceRuntimeServices, +} from "@paperclipai/db"; +import { eq } from "drizzle-orm"; import { cleanupExecutionWorkspaceArtifacts, ensureRuntimeServicesForRun, normalizeAdapterManagedRuntimeServices, + reconcilePersistedRuntimeServicesOnStartup, realizeExecutionWorkspace, releaseRuntimeServicesForRun, + resetRuntimeServicesForTests, + sanitizeRuntimeServiceBaseEnv, stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; +import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; const execFileAsync = promisify(execFile); const leasedRunIds = new Set(); +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} async function runGit(cwd: string, args: string[]) { await execFileAsync("git", args, { cwd }); @@ -124,7 +152,30 @@ afterEach(async () => { delete process.env.PAPERCLIP_CONFIG; delete process.env.PAPERCLIP_HOME; delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_WORKTREES_DIR; delete process.env.DATABASE_URL; + await resetRuntimeServicesForTests(); +}); + +describe("sanitizeRuntimeServiceBaseEnv", () => { + it("removes inherited Paperclip and pnpm auth flags before spawning runtime services", () => { + const sanitized = sanitizeRuntimeServiceBaseEnv({ + PATH: process.env.PATH, + DATABASE_URL: "postgres://example.test/paperclip", + PAPERCLIP_HOME: "/tmp/paperclip-home", + PAPERCLIP_INSTANCE_ID: "runtime-instance", + npm_config_tailscale_auth: "true", + npm_config_authenticated_private: "true", + HOST: "0.0.0.0", + }); + + expect(sanitized.PAPERCLIP_HOME).toBeUndefined(); + expect(sanitized.PAPERCLIP_INSTANCE_ID).toBeUndefined(); + expect(sanitized.DATABASE_URL).toBeUndefined(); + expect(sanitized.npm_config_tailscale_auth).toBeUndefined(); + expect(sanitized.npm_config_authenticated_private).toBeUndefined(); + expect(sanitized.HOST).toBe("0.0.0.0"); + }); }); describe("realizeExecutionWorkspace", () => { @@ -196,6 +247,77 @@ describe("realizeExecutionWorkspace", () => { expect(second.branchName).toBe(first.branchName); }); + it("slugifies unsafe issue titles for branch names and worktree folders", async () => { + const repoRoot = await createTempRepo(); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + }, + }, + issue: { + id: "issue-unsafe", + identifier: "PAP-991", + title: "there should be a setting for the allowance of thumbs up / thumbs down data; `rm -rf`", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(realized.branchName).toBe( + "PAP-991-there-should-be-a-setting-for-the-allowance-of-thumbs-up-thumbs-down-data-rm-rf", + ); + expect(realized.branchName?.includes("/")).toBe(false); + expect(path.basename(realized.cwd)).toBe(realized.branchName); + }); + + it("preserves intentional slashes and dots from the branch template", async () => { + const repoRoot = await createTempRepo(); + + const realized = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "release/{{issue.identifier}}.{{slug}}", + }, + }, + issue: { + id: "issue-template-safe", + identifier: "PAP-992", + title: "Hotfix / April.1", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect(realized.branchName).toBe("release/PAP-992.hotfix-april-1"); + expect(path.basename(realized.cwd)).toBe("PAP-992.hotfix-april-1"); + }); + it("runs a configured provision command inside the derived worktree", async () => { const repoRoot = await createTempRepo(); await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); @@ -282,6 +404,156 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); }); + it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => { + const repoRoot = await createTempRepo(); + const previousCwd = process.cwd(); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-")); + const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-")); + const instanceId = "worktree-base"; + const sharedConfigDir = path.join(paperclipHome, "instances", instanceId); + const sharedConfigPath = path.join(sharedConfigDir, "config.json"); + const sharedEnvPath = path.join(sharedConfigDir, ".env"); + + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome; + + await fs.mkdir(sharedConfigDir, { recursive: true }); + await fs.writeFile( + sharedConfigPath, + JSON.stringify( + { + $meta: { + version: 1, + updatedAt: "2026-03-26T00:00:00.000Z", + source: "doctor", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(sharedConfigDir, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sharedConfigDir, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(sharedConfigDir, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(sharedConfigDir, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(sharedConfigDir, "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile(sharedEnvPath, 'DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"\n', "utf8"); + + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.copyFile( + fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)), + path.join(repoRoot, "scripts", "provision-worktree.sh"), + ); + await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]); + + try { + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-885", + title: "Show worktree banner", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + const configPath = path.join(workspace.cwd, ".paperclip", "config.json"); + const envPath = path.join(workspace.cwd, ".paperclip", ".env"); + const envContents = await fs.readFile(envPath, "utf8"); + const configContents = JSON.parse(await fs.readFile(configPath, "utf8")); + const configStats = await fs.lstat(configPath); + const expectedInstanceId = "pap-885-show-worktree-banner"; + const expectedInstanceRoot = path.join( + isolatedWorktreeHome, + "instances", + expectedInstanceId, + ); + + expect(configStats.isSymbolicLink()).toBe(false); + expect(configContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db")); + expect(configContents.database.embeddedPostgresDataDir).not.toBe(path.join(sharedConfigDir, "db")); + expect(configContents.server.port).not.toBe(3100); + expect(configContents.secrets.localEncrypted.keyFilePath).toBe( + path.join(expectedInstanceRoot, "secrets", "master.key"), + ); + expect(envContents).not.toContain("DATABASE_URL="); + expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`); + expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`); + expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); + expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true"); + expect(envContents).toContain( + `PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`, + ); + + process.chdir(workspace.cwd); + expect(resolvePaperclipConfigPath()).toBe(configPath); + } finally { + process.chdir(previousCwd); + } + }); + it("records worktree setup and provision operations when a recorder is provided", async () => { const repoRoot = await createTempRepo(); const { recorder, operations } = createWorkspaceOperationRecorderDouble(); @@ -681,6 +953,101 @@ describe("ensureRuntimeServicesForRun", () => { expect(third[0]?.id).not.toBe(first[0]?.id); }); + it("does not reuse project-scoped shared services across different workspace launch contexts", async () => { + const primaryWorkspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-primary-")); + const worktreeWorkspaceRoot = path.join(primaryWorkspaceRoot, ".paperclip", "worktrees", "PAP-874-chat-speed-issues"); + await fs.mkdir(worktreeWorkspaceRoot, { recursive: true }); + + const primaryWorkspace = buildWorkspace(primaryWorkspaceRoot); + const executionWorkspace: RealizedExecutionWorkspace = { + ...buildWorkspace(worktreeWorkspaceRoot), + source: "task_session", + strategy: "git_worktree", + cwd: worktreeWorkspaceRoot, + branchName: "PAP-874-chat-speed-issues", + worktreePath: worktreeWorkspaceRoot, + }; + const serviceCommand = + "node -e \"require('node:http').createServer((req,res)=>res.end(process.env.PAPERCLIP_HOME)).listen(Number(process.env.PORT), '127.0.0.1')\""; + const config = { + workspaceRuntime: { + services: [ + { + name: "paperclip-dev", + command: serviceCommand, + cwd: ".", + env: { + PAPERCLIP_HOME: "{{workspace.cwd}}/.paperclip/runtime-services", + }, + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + lifecycle: "shared", + reuseScope: "project_workspace", + stopPolicy: { + type: "on_run_finish", + }, + }, + ], + }, + }; + + const primaryRunId = "run-project-workspace"; + const executionRunId = "run-execution-workspace"; + leasedRunIds.add(primaryRunId); + leasedRunIds.add(executionRunId); + + const primaryServices = await ensureRuntimeServicesForRun({ + runId: primaryRunId, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace: primaryWorkspace, + config, + adapterEnv: {}, + }); + + const executionServices = await ensureRuntimeServicesForRun({ + runId: executionRunId, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + issue: null, + workspace: executionWorkspace, + executionWorkspaceId: "execution-workspace-1", + config, + adapterEnv: {}, + }); + + expect(primaryServices).toHaveLength(1); + expect(executionServices).toHaveLength(1); + expect(primaryServices[0]?.reused).toBe(false); + expect(executionServices[0]?.reused).toBe(false); + expect(executionServices[0]?.id).not.toBe(primaryServices[0]?.id); + expect(executionServices[0]?.executionWorkspaceId).toBe("execution-workspace-1"); + expect(executionServices[0]?.cwd).toBe(worktreeWorkspaceRoot); + expect(executionServices[0]?.url).not.toBe(primaryServices[0]?.url); + + const primaryResponse = await fetch(primaryServices[0]!.url!); + expect(await primaryResponse.text()).toBe(path.join(primaryWorkspaceRoot, ".paperclip", "runtime-services")); + + const executionResponse = await fetch(executionServices[0]!.url!); + expect(await executionResponse.text()).toBe(path.join(worktreeWorkspaceRoot, ".paperclip", "runtime-services")); + }); + it("does not leak parent Paperclip instance env into runtime service commands", async () => { const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-env-")); const workspace = buildWorkspace(workspaceRoot); @@ -875,6 +1242,258 @@ describe("ensureRuntimeServicesForRun", () => { }); }); +describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-workspace-runtime-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + afterEach(async () => { + await db.delete(workspaceRuntimeServices); + await db.delete(executionWorkspaces); + await db.delete(projects); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + it("adopts a live auto-port shared service after runtime state is reset", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-reconcile-")); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-")); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = `runtime-reconcile-${randomUUID()}`; + + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Codex Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + startedAt: new Date(), + updatedAt: new Date(), + }); + + const workspace = { + ...buildWorkspace(workspaceRoot), + projectId: null, + workspaceId: null, + }; + leasedRunIds.add(runId); + + const services = await ensureRuntimeServicesForRun({ + db, + runId, + agent: { + id: agentId, + name: "Codex Coder", + companyId, + }, + issue: null, + workspace, + config: { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "agent", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services).toHaveLength(1); + const service = services[0]; + expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true }); + + await resetRuntimeServicesForTests(); + + const result = await reconcilePersistedRuntimeServicesOnStartup(db); + expect(result).toMatchObject({ reconciled: 1, adopted: 1, stopped: 0 }); + + const persisted = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, service!.id)) + .then((rows) => rows[0] ?? null); + expect(persisted?.status).toBe("running"); + expect(persisted?.providerRef).toMatch(/^\d+$/); + + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: workspace.cwd, + }); + + await expect(fetch(service!.url!)).rejects.toThrow(); + }); + + it("persists controlled execution workspace stops as stopped", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-stop-persisted-")); + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + const runId = randomUUID(); + const executionWorkspaceId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Codex Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime stop test", + status: "active", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Execution workspace stop test", + status: "active", + cwd: workspaceRoot, + providerType: "local_fs", + providerRef: workspaceRoot, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + startedAt: new Date(), + updatedAt: new Date(), + }); + + const workspace = { + ...buildWorkspace(workspaceRoot), + projectId: null, + workspaceId: null, + }; + leasedRunIds.add(runId); + + const services = await ensureRuntimeServicesForRun({ + db, + runId, + agent: { + id: agentId, + name: "Codex Coder", + companyId, + }, + issue: null, + workspace, + executionWorkspaceId, + config: { + workspaceRuntime: { + services: [ + { + name: "web", + command: + "node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services[0]?.url).toBeTruthy(); + + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: workspace.cwd, + }); + await releaseRuntimeServicesForRun(runId); + leasedRunIds.delete(runId); + await new Promise((resolve) => setTimeout(resolve, 250)); + + await expect(fetch(services[0]!.url!)).rejects.toThrow(); + + const persisted = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.id, services[0]!.id)) + .then((rows) => rows[0] ?? null); + + expect(persisted?.status).toBe("stopped"); + expect(persisted?.healthStatus).toBe("unknown"); + expect(persisted?.stoppedAt).toBeTruthy(); + }); +}); + describe("normalizeAdapterManagedRuntimeServices", () => { it("fills workspace defaults and derives stable ids for adapter-managed services", () => { const workspace = buildWorkspace("/tmp/project"); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts new file mode 100644 index 00000000..3317a254 --- /dev/null +++ b/server/src/__tests__/worktree-config.test.ts @@ -0,0 +1,426 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + applyRuntimePortSelectionToConfig, + maybePersistWorktreeRuntimePorts, + maybeRepairLegacyWorktreeConfigAndEnvFiles, +} from "../worktree-config.js"; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_CWD = process.cwd(); + +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)) { + process.env[key] = value; + } +}); + +function buildLegacyConfig(sharedRoot: string) { + return { + $meta: { + version: 1, + updatedAt: "2026-03-26T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres" as const, + embeddedPostgresDataDir: path.join(sharedRoot, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sharedRoot, "data", "backups"), + }, + }, + logging: { + mode: "file" as const, + logDir: path.join(sharedRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted" as const, + exposure: "private" as const, + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "explicit" as const, + publicBaseUrl: "http://127.0.0.1:3100", + disableSignUp: false, + }, + storage: { + provider: "local_disk" as const, + localDisk: { + baseDir: path.join(sharedRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted" as const, + strictMode: false, + localEncrypted: { + keyFilePath: path.join(sharedRoot, "secrets", "master.key"), + }, + }, + }; +} + +describe("worktree config repair", () => { + it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-")); + const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component", + "PAPERCLIP_AGENT_JWT_SECRET=shared-secret", + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_CONFIG; + delete process.env.PAPERCLIP_CONTEXT; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: true, + }); + + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component"); + + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups")); + expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs")); + expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"'); + expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`); + expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`); + expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"'); + expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); + expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component"); + }); + + it("avoids sibling worktree ports when repairing legacy configs", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-")); + const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(siblingInstanceRoot, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-880-thumbs-capture-for-evals-feature", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(siblingInstanceRoot, "config.json"), + JSON.stringify( + { + ...buildLegacyConfig(siblingInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "data", "backups"), + }, + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(result.repairedConfig).toBe(true); + expect(repairedConfig.server.port).toBe(3102); + expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); + }); + + it("rebalances duplicate ports for already isolated worktree configs", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-")); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees"); + const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox"); + const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component"); + const paperclipDir = path.join(currentWorktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component"); + const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(currentInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(currentInstanceRoot, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(currentInstanceRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(currentInstanceRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + siblingConfigPath, + JSON.stringify( + { + ...buildLegacyConfig(siblingInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "data", "backups"), + }, + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(currentWorktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(result.repairedConfig).toBe(true); + expect(repairedConfig.server.port).toBe(3102); + expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); + }); + + it("persists runtime-selected worktree ports back into config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-")); + const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(instanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(instanceRoot, "db"), + embeddedPostgresPort: 54331, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(instanceRoot, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(instanceRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(instanceRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(instanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox"; + process.env.PAPERCLIP_HOME = isolatedHome; + process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox"; + process.env.PAPERCLIP_CONFIG = configPath; + + maybePersistWorktreeRuntimePorts({ + serverPort: 3103, + databasePort: 54335, + }); + + const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(writtenConfig.server.port).toBe(3103); + expect(writtenConfig.database.embeddedPostgresPort).toBe(54335); + expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/"); + }); + + it("can update the in-memory config without rewriting env-driven ports", () => { + const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), { + serverPort: 3104, + databasePort: 54340, + allowServerPortWrite: false, + allowDatabasePortWrite: true, + }); + + expect(changed).toBe(true); + expect(config.server.port).toBe(3100); + expect(config.database.embeddedPostgresPort).toBe(54340); + expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/"); + }); +}); diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 8d86eb52..8be40a51 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -1,4 +1,4 @@ -export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js"; +export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js"; export type { ServerAdapterModule, AdapterExecutionContext, diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts index 1d0cf9f6..ff2bf82e 100644 --- a/server/src/adapters/process/execute.ts +++ b/server/src/adapters/process/execute.ts @@ -5,7 +5,9 @@ import { asStringArray, parseObject, buildPaperclipEnv, - redactEnvForLogs, + buildInvocationEnvForLogs, + ensurePathInEnv, + resolveCommandForLogs, runChildProcess, } from "../utils.js"; @@ -21,6 +23,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise detectModelFromHermes(), }; const adaptersByType = new Map( @@ -219,6 +226,15 @@ export function listServerAdapters(): ServerAdapterModule[] { return Array.from(adaptersByType.values()); } +export async function detectAdapterModel( + type: string, +): Promise<{ model: string; provider: string; source: string } | null> { + const adapter = adaptersByType.get(type); + if (!adapter?.detectModel) return null; + const detected = await adapter.detectModel(); + return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null; +} + export function findServerAdapter(type: string): ServerAdapterModule | null { return adaptersByType.get(type) ?? null; } diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index cb4ec4b9..6dfccfcb 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -1,32 +1,78 @@ // Re-export everything from the shared adapter-utils/server-utils package. // This file is kept as a convenience shim so existing in-tree // imports (process/, http/, heartbeat.ts) don't need rewriting. +import type { ChildProcess } from "node:child_process"; import { logger } from "../middleware/logger.js"; -export { - type RunProcessResult, - runningProcesses, - MAX_CAPTURE_BYTES, - MAX_EXCERPT_BYTES, - parseObject, - asString, - asNumber, - asBoolean, - asStringArray, - parseJson, - appendWithCap, - resolvePathValue, - renderTemplate, - redactEnvForLogs, - buildPaperclipEnv, - defaultPathForPlatform, - ensurePathInEnv, - ensureAbsoluteDirectory, - ensureCommandResolvable, -} from "@paperclipai/adapter-utils/server-utils"; +import * as serverUtils from "@paperclipai/adapter-utils/server-utils"; +export type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; + +type BuildInvocationEnvForLogsOptions = { + runtimeEnv?: NodeJS.ProcessEnv | Record; + includeRuntimeKeys?: string[]; + resolvedCommand?: string | null; + resolvedCommandEnvKey?: string; +}; + +export const runningProcesses: Map = + serverUtils.runningProcesses; +export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES; +export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES; +export const parseObject = serverUtils.parseObject; +export const asString = serverUtils.asString; +export const asNumber = serverUtils.asNumber; +export const asBoolean = serverUtils.asBoolean; +export const asStringArray = serverUtils.asStringArray; +export const parseJson = serverUtils.parseJson; +export const appendWithCap = serverUtils.appendWithCap; +export const resolvePathValue = serverUtils.resolvePathValue; +export const renderTemplate = serverUtils.renderTemplate; +export const redactEnvForLogs = serverUtils.redactEnvForLogs; +export const buildPaperclipEnv = serverUtils.buildPaperclipEnv; +export const defaultPathForPlatform = serverUtils.defaultPathForPlatform; +export const ensurePathInEnv = serverUtils.ensurePathInEnv; +export const ensureAbsoluteDirectory = serverUtils.ensureAbsoluteDirectory; +export const ensureCommandResolvable = serverUtils.ensureCommandResolvable; +export const resolveCommandForLogs = serverUtils.resolveCommandForLogs; + +export function buildInvocationEnvForLogs( + env: Record, + options: BuildInvocationEnvForLogsOptions = {}, +): Record { + // TODO: Remove this fallback once @paperclipai/adapter-utils exports buildInvocationEnvForLogs everywhere we consume it. + const maybeBuildInvocationEnvForLogs = ( + serverUtils as typeof serverUtils & { + buildInvocationEnvForLogs?: ( + env: Record, + options?: BuildInvocationEnvForLogsOptions, + ) => Record; + } + ).buildInvocationEnvForLogs; + + if (typeof maybeBuildInvocationEnvForLogs === "function") { + return maybeBuildInvocationEnvForLogs(env, options); + } + + const merged: Record = { ...env }; + const runtimeEnv = options.runtimeEnv ?? {}; + + for (const key of options.includeRuntimeKeys ?? []) { + if (key in merged) continue; + const value = runtimeEnv[key]; + if (typeof value !== "string" || value.length === 0) continue; + merged[key] = value; + } + + const resolvedCommand = options.resolvedCommand?.trim(); + if (resolvedCommand) { + merged[options.resolvedCommandEnvKey ?? "PAPERCLIP_RESOLVED_COMMAND"] = resolvedCommand; + } + + return redactEnvForLogs(merged); +} // Re-export runChildProcess with the server's pino logger wired in. -import { runChildProcess as _runChildProcess } from "@paperclipai/adapter-utils/server-utils"; import type { RunProcessResult } from "@paperclipai/adapter-utils/server-utils"; +const _runChildProcess = serverUtils.runChildProcess; export async function runChildProcess( runId: string, diff --git a/server/src/config.ts b/server/src/config.ts index 6943af7a..4a1cc17b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; +import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js"; import { AUTH_BASE_URL_MODES, DEPLOYMENT_EXPOSURES, @@ -36,6 +37,8 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) { loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true }); } +maybeRepairLegacyWorktreeConfigAndEnvFiles(); + type DatabaseMode = "embedded-postgres" | "postgres"; export interface Config { diff --git a/server/src/dev-watch-ignore.ts b/server/src/dev-watch-ignore.ts new file mode 100644 index 00000000..cd618f73 --- /dev/null +++ b/server/src/dev-watch-ignore.ts @@ -0,0 +1,36 @@ +import fs from "node:fs"; +import path from "node:path"; + +function toGlobstarPath(candidate: string): string { + return `${candidate.replaceAll(path.sep, "/")}/**`; +} + +function addIgnorePath(target: Set, candidate: string): void { + target.add(candidate); + target.add(toGlobstarPath(candidate)); + try { + const realPath = fs.realpathSync(candidate); + target.add(realPath); + target.add(toGlobstarPath(realPath)); + } catch { + // Ignore paths that do not exist in the current checkout. + } +} + +export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] { + const ignorePaths = new Set([ + "**/{node_modules,bower_components,vendor}/**", + "**/.vite-temp/**", + ]); + + for (const relativePath of [ + "../ui/node_modules", + "../ui/node_modules/.vite-temp", + "../ui/.vite", + "../ui/dist", + ]) { + addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath)); + } + + return [...ignorePaths]; +} diff --git a/server/src/index.ts b/server/src/index.ts index d4f41c6e..7ebfa7d1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,9 +10,11 @@ import { and, eq } from "drizzle-orm"; import { createDb, ensurePostgresDatabase, + formatEmbeddedPostgresError, getPostgresDataDirectory, inspectMigrations, applyPendingMigrations, + createEmbeddedPostgresLogBuffer, reconcilePendingMigrationHistory, formatDatabaseBackupResult, runDatabaseBackup, @@ -30,6 +32,7 @@ import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineSe import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; +import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; type BetterAuthSessionUser = { id: string; @@ -69,7 +72,7 @@ export interface StartedServer { } export async function startServer(): Promise { - const config = loadConfig(); + let config = loadConfig(); if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; } @@ -167,6 +170,18 @@ export async function startServer(): Promise { const normalized = host.trim().toLowerCase(); return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; } + + function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } + } const LOCAL_BOARD_USER_ID = "local-board"; const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; @@ -233,6 +248,7 @@ export async function startServer(): Promise { let embeddedPostgresStartedByThisProcess = false; let migrationSummary: MigrationSummary = "skipped"; let activeDatabaseConnectionString: string; + let resolvedEmbeddedPostgresPort: number | null = null; let startupDbInfo: | { mode: "external-postgres"; connectionString: string } | { mode: "embedded-postgres"; dataDir: string; port: number }; @@ -258,29 +274,31 @@ export async function startServer(): Promise { const dataDir = resolve(config.embeddedPostgresDataDir); const configuredPort = config.embeddedPostgresPort; let port = configuredPort; - const embeddedPostgresLogBuffer: string[] = []; - const EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT = 120; + const logBuffer = createEmbeddedPostgresLogBuffer(120); const verboseEmbeddedPostgresLogs = process.env.PAPERCLIP_EMBEDDED_POSTGRES_VERBOSE === "true"; const appendEmbeddedPostgresLog = (message: unknown) => { - const text = typeof message === "string" ? message : message instanceof Error ? message.message : String(message ?? ""); - for (const lineRaw of text.split(/\r?\n/)) { + logBuffer.append(message); + if (!verboseEmbeddedPostgresLogs) { + return; + } + const lines = typeof message === "string" + ? message.split(/\r?\n/) + : message instanceof Error + ? [message.message] + : [String(message ?? "")]; + for (const lineRaw of lines) { const line = lineRaw.trim(); if (!line) continue; - embeddedPostgresLogBuffer.push(line); - if (embeddedPostgresLogBuffer.length > EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT) { - embeddedPostgresLogBuffer.splice(0, embeddedPostgresLogBuffer.length - EMBEDDED_POSTGRES_LOG_BUFFER_LIMIT); - } - if (verboseEmbeddedPostgresLogs) { - logger.info({ embeddedPostgresLog: line }, "embedded-postgres"); - } + logger.info({ embeddedPostgresLog: line }, "embedded-postgres"); } }; const logEmbeddedPostgresFailure = (phase: "initialise" | "start", err: unknown) => { - if (embeddedPostgresLogBuffer.length > 0) { + const recentLogs = logBuffer.getRecentLogs(); + if (recentLogs.length > 0) { logger.error( { phase, - recentLogs: embeddedPostgresLogBuffer, + recentLogs, err, }, "Embedded PostgreSQL failed; showing buffered startup logs", @@ -357,7 +375,10 @@ export async function startServer(): Promise { await embeddedPostgres.initialise(); } catch (err) { logEmbeddedPostgresFailure("initialise", err); - throw err; + throw formatEmbeddedPostgresError(err, { + fallbackMessage: `Failed to initialize embedded PostgreSQL cluster in ${dataDir} on port ${port}`, + recentLogs: logBuffer.getRecentLogs(), + }); } } else { logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`); @@ -371,7 +392,10 @@ export async function startServer(): Promise { await embeddedPostgres.start(); } catch (err) { logEmbeddedPostgresFailure("start", err); - throw err; + throw formatEmbeddedPostgresError(err, { + fallbackMessage: `Failed to start embedded PostgreSQL on port ${port}`, + recentLogs: logBuffer.getRecentLogs(), + }); } embeddedPostgresStartedByThisProcess = true; } @@ -395,6 +419,7 @@ export async function startServer(): Promise { db = createDb(embeddedConnectionString); logger.info("Embedded PostgreSQL ready"); activeDatabaseConnectionString = embeddedConnectionString; + resolvedEmbeddedPostgresPort = port; startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } @@ -476,6 +501,19 @@ export async function startServer(): Promise { } const listenPort = await detectPort(config.port); + if (listenPort !== config.port) { + config.port = listenPort; + } + if (resolvedEmbeddedPostgresPort !== null && resolvedEmbeddedPostgresPort !== config.embeddedPostgresPort) { + config.embeddedPostgresPort = resolvedEmbeddedPostgresPort; + } + if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) { + config.authPublicBaseUrl = rewriteLocalUrlPort(config.authPublicBaseUrl, listenPort); + } + maybePersistWorktreeRuntimePorts({ + serverPort: listenPort, + databasePort: resolvedEmbeddedPostgresPort, + }); const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const storageService = createStorageServiceFromConfig(config); const app = await createApp(db as any, { diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index de66a4ce..feff3b40 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -18,7 +18,8 @@ function parseOrigin(value: string | undefined) { function trustedOriginsForRequest(req: Request) { const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase())); - const host = req.header("host")?.trim(); + const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); + const host = forwardedHost || req.header("host")?.trim(); if (host) { origins.add(`http://${host}`.toLowerCase()); origins.add(`https://${host}`.toLowerCase()); diff --git a/server/src/onboarding-assets/ceo/AGENTS.md b/server/src/onboarding-assets/ceo/AGENTS.md index f971561b..c9aee7d4 100644 --- a/server/src/onboarding-assets/ceo/AGENTS.md +++ b/server/src/onboarding-assets/ceo/AGENTS.md @@ -1,9 +1,39 @@ -You are the CEO. +You are the CEO. Your job is to lead the company, not to do individual contributor work. You own strategy, prioritization, and cross-functional coordination. Your home directory is $AGENT_HOME. Everything personal to you -- life, memory, knowledge -- lives there. Other agents may have their own folders and you may update them when necessary. Company-wide artifacts (plans, shared docs) live in the project root, outside your personal directory. +## Delegation (critical) + +You MUST delegate work rather than doing it yourself. When a task is assigned to you: + +1. **Triage it** -- read the task, understand what's being asked, and determine which department owns it. +2. **Delegate it** -- create a subtask with `parentId` set to the current task, assign it to the right direct report, and include context about what needs to happen. Use these routing rules: + - **Code, bugs, features, infra, devtools, technical tasks** → CTO + - **Marketing, content, social media, growth, devrel** → CMO + - **UX, design, user research, design-system** → UXDesigner + - **Cross-functional or unclear** → break into separate subtasks for each department, or assign to the CTO if it's primarily technical with a design component + - If the right report doesn't exist yet, use the `paperclip-create-agent` skill to hire one before delegating. +3. **Do NOT write code, implement features, or fix bugs yourself.** Your reports exist for this. Even if a task seems small or quick, delegate it. +4. **Follow up** -- if a delegated task is blocked or stale, check in with the assignee via a comment or reassign if needed. + +## What you DO personally + +- Set priorities and make product decisions +- Resolve cross-team conflicts or ambiguity +- Communicate with the board (human users) +- Approve or reject proposals from your reports +- Hire new agents when the team needs capacity +- Unblock your direct reports when they escalate to you + +## Keeping work moving + +- Don't let tasks sit idle. If you delegate something, check that it's progressing. +- If a report is blocked, help unblock them -- escalate to the board if needed. +- If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work. +- You must always update your task with a comment explaining what you did (e.g., who you delegated to and why). + ## Memory and Planning You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, qmd recall, and planning conventions. diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 161348a2..bc6273c7 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -37,7 +37,7 @@ If `PAPERCLIP_APPROVAL_ID` is set: ## 6. Delegation -- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. +- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue. - Use `paperclip-create-agent` skill when hiring new agents. - Assign work to the right agent for the job. diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 7d7dfe2b..a53bf0dc 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -14,6 +14,7 @@ import type { Db } from "@paperclipai/db"; import { agentApiKeys, authUsers, + companies, invites, joinRequests } from "@paperclipai/db"; @@ -856,7 +857,8 @@ export function normalizeAgentDefaultsForJoin(input: { function toInviteSummaryResponse( req: Request, token: string, - invite: typeof invites.$inferSelect + invite: typeof invites.$inferSelect, + companyName: string | null = null ) { const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; @@ -865,6 +867,7 @@ function toInviteSummaryResponse( return { id: invite.id, companyId: invite.companyId, + companyName, inviteType: invite.inviteType, allowedJoinTypes: invite.allowedJoinTypes, expiresAt: invite.expiresAt, @@ -993,6 +996,7 @@ function buildInviteOnboardingManifest( token: string, invite: typeof invites.$inferSelect, opts: { + companyName?: string | null; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; bindHost: string; @@ -1024,7 +1028,12 @@ function buildInviteOnboardingManifest( }); return { - invite: toInviteSummaryResponse(req, token, invite), + invite: toInviteSummaryResponse( + req, + token, + invite, + opts.companyName ?? null + ), onboarding: { instructions: "Join as an OpenClaw Gateway agent, save your one-time claim secret, wait for board approval, then claim your API key. Save the claim response token to ~/.openclaw/workspace/paperclip-claimed-api-key.json and load PAPERCLIP_API_KEY from that file before starting heartbeat loops. You MUST submit adapterType='openclaw_gateway', set agentDefaultsPayload.url to your ws:// or wss:// OpenClaw gateway endpoint, and include agentDefaultsPayload.headers.x-openclaw-token (or legacy x-openclaw-auth).", @@ -1084,6 +1093,7 @@ export function buildInviteOnboardingTextDocument( token: string, invite: typeof invites.$inferSelect, opts: { + companyName?: string | null; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; bindHost: string; @@ -1133,6 +1143,10 @@ export function buildInviteOnboardingTextDocument( - expiresAt: ${invite.expiresAt.toISOString()} `); + if (manifest.invite.companyName) { + lines.push(`- companyName: ${manifest.invite.companyName}`); + } + if (onboarding.inviteMessage) { appendBlock(` ## Message from inviter @@ -1882,6 +1896,16 @@ export function accessRoutes( return { token, created, normalizedAgentMessage }; } + async function getInviteCompanyName(companyId: string | null) { + if (!companyId) return null; + const company = await db + .select({ name: companies.name }) + .from(companies) + .where(eq(companies.id, companyId)) + .then((rows) => rows[0] ?? null); + return company?.name ?? null; + } + router.get("/skills/available", (_req, res) => { res.json({ skills: listAvailableSkills() }); }); @@ -1942,11 +1966,18 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const companyName = await getInviteCompanyName(created.companyId); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + companyName + ); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -1987,11 +2018,18 @@ export function accessRoutes( } }); - const inviteSummary = toInviteSummaryResponse(req, token, created); + const companyName = await getInviteCompanyName(created.companyId); + const inviteSummary = toInviteSummaryResponse( + req, + token, + created, + companyName + ); res.status(201).json({ ...created, token, inviteUrl: `/invite/${token}`, + companyName, onboardingTextPath: inviteSummary.onboardingTextPath, onboardingTextUrl: inviteSummary.onboardingTextUrl, inviteMessage: inviteSummary.inviteMessage @@ -2016,7 +2054,8 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(toInviteSummaryResponse(req, token, invite)); + const companyName = await getInviteCompanyName(invite.companyId); + res.json(toInviteSummaryResponse(req, token, invite, companyName)); }); router.get("/invites/:token/onboarding", async (req, res) => { @@ -2031,7 +2070,11 @@ export function accessRoutes( throw notFound("Invite not found"); } - res.json(buildInviteOnboardingManifest(req, token, invite, opts)); + const companyName = await getInviteCompanyName(invite.companyId); + res.json(buildInviteOnboardingManifest(req, token, invite, { + ...opts, + companyName + })); }); router.get("/invites/:token/onboarding.txt", async (req, res) => { @@ -2046,9 +2089,15 @@ export function accessRoutes( throw notFound("Invite not found"); } + const companyName = await getInviteCompanyName(invite.companyId); res .type("text/plain; charset=utf-8") - .send(buildInviteOnboardingTextDocument(req, token, invite, opts)); + .send( + buildInviteOnboardingTextDocument(req, token, invite, { + ...opts, + companyName + }) + ); }); router.get("/invites/:token/test-resolution", async (req, res) => { @@ -2458,11 +2507,15 @@ export function accessRoutes( const response = toJoinRequestResponse(created); if (claimSecret) { + const companyName = await getInviteCompanyName(invite.companyId); const onboardingManifest = buildInviteOnboardingManifest( req, token, invite, - opts + { + ...opts, + companyName + } ); res.status(202).json({ ...response, diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f642eb10..2ad85e63 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -6,6 +6,7 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, + agentMineInboxQuerySchema, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -44,7 +45,7 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; -import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; +import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; @@ -671,6 +672,15 @@ export function agentRoutes(db: Db) { res.json(models); }); + router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const type = req.params.type as string; + + const detected = await detectAdapterModel(type); + res.json(detected); + }); + router.post( "/companies/:companyId/adapters/:type/test-environment", validate(testAdapterEnvironmentSchema), @@ -997,6 +1007,23 @@ export function agentRoutes(db: Db) { ); }); + router.get("/agents/me/inbox/mine", async (req, res) => { + if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { + res.status(401).json({ error: "Agent authentication required" }); + return; + } + + const query = agentMineInboxQuerySchema.parse(req.query); + const issuesSvc = issueService(db); + const rows = await issuesSvc.list(req.actor.companyId, { + touchedByUserId: query.userId, + inboxArchivedByUserId: query.userId, + status: query.status, + }); + + res.json(rows); + }); + router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index a7276704..4fa20425 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -5,15 +5,16 @@ import { issues, projects, projectWorkspaces } from "@paperclipai/db"; import { updateExecutionWorkspaceSchema } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js"; +import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js"; import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js"; +import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js"; import { cleanupExecutionWorkspaceArtifacts, + startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForExecutionWorkspace, } from "../services/workspace-runtime.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; -const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); - export function executionWorkspaceRoutes(db: Db) { const router = Router(); const svc = executionWorkspaceService(db); @@ -43,6 +44,202 @@ export function executionWorkspaceRoutes(db: Db) { res.json(workspace); }); + router.get("/execution-workspaces/:id/close-readiness", async (req, res) => { + const id = req.params.id as string; + const workspace = await svc.getById(id); + if (!workspace) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + assertCompanyAccess(req, workspace.companyId); + const readiness = await svc.getCloseReadiness(id); + if (!readiness) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + res.json(readiness); + }); + + router.get("/execution-workspaces/:id/workspace-operations", async (req, res) => { + const id = req.params.id as string; + const workspace = await svc.getById(id); + if (!workspace) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + assertCompanyAccess(req, workspace.companyId); + const operations = await workspaceOperationsSvc.listForExecutionWorkspace(id); + res.json(operations); + }); + + router.post("/execution-workspaces/:id/runtime-services/:action", async (req, res) => { + const id = req.params.id as string; + const action = String(req.params.action ?? "").trim().toLowerCase(); + if (action !== "start" && action !== "stop" && action !== "restart") { + res.status(404).json({ error: "Runtime service action not found" }); + return; + } + + const existing = await svc.getById(id); + if (!existing) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + assertCompanyAccess(req, existing.companyId); + + const workspaceCwd = existing.cwd; + if (!workspaceCwd) { + res.status(422).json({ error: "Execution workspace needs a local path before Paperclip can manage local runtime services" }); + return; + } + + const projectWorkspace = existing.projectWorkspaceId + ? await db + .select({ + id: projectWorkspaces.id, + cwd: projectWorkspaces.cwd, + repoUrl: projectWorkspaces.repoUrl, + repoRef: projectWorkspaces.repoRef, + defaultRef: projectWorkspaces.defaultRef, + metadata: projectWorkspaces.metadata, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.id, existing.projectWorkspaceId), + eq(projectWorkspaces.companyId, existing.companyId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig( + (projectWorkspace?.metadata as Record | null) ?? null, + )?.workspaceRuntime ?? null; + const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null; + + if ((action === "start" || action === "restart") && !effectiveRuntimeConfig) { + res.status(422).json({ error: "Execution workspace has no runtime service configuration or inherited project workspace default" }); + return; + } + + const actor = getActorInfo(req); + const recorder = workspaceOperationsSvc.createRecorder({ + companyId: existing.companyId, + executionWorkspaceId: existing.id, + }); + let runtimeServiceCount = existing.runtimeServices?.length ?? 0; + const stdout: string[] = []; + const stderr: string[] = []; + + const operation = await recorder.recordOperation({ + phase: action === "stop" ? "workspace_teardown" : "workspace_provision", + command: `workspace runtime ${action}`, + cwd: existing.cwd, + metadata: { + action, + executionWorkspaceId: existing.id, + }, + run: async () => { + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stdout") stdout.push(chunk); + else stderr.push(chunk); + }; + + if (action === "stop" || action === "restart") { + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId: existing.id, + workspaceCwd, + }); + } + + if (action === "start" || action === "restart") { + const startedServices = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: existing.companyId, + }, + issue: existing.sourceIssueId + ? { + id: existing.sourceIssueId, + identifier: null, + title: existing.name, + } + : null, + workspace: { + baseCwd: workspaceCwd, + source: existing.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: existing.projectId, + workspaceId: existing.projectWorkspaceId, + repoUrl: existing.repoUrl, + repoRef: existing.baseRef, + strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary", + cwd: workspaceCwd, + branchName: existing.branchName, + worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null, + warnings: [], + created: false, + }, + executionWorkspaceId: existing.id, + config: { workspaceRuntime: effectiveRuntimeConfig }, + adapterEnv: {}, + onLog, + }); + runtimeServiceCount = startedServices.length; + } else { + runtimeServiceCount = 0; + } + + const metadata = mergeExecutionWorkspaceConfig(existing.metadata as Record | null, { + desiredState: action === "stop" ? "stopped" : "running", + }); + await svc.update(existing.id, { metadata }); + + return { + status: "succeeded", + stdout: stdout.join(""), + stderr: stderr.join(""), + system: + action === "stop" + ? "Stopped execution workspace runtime services.\n" + : action === "restart" + ? "Restarted execution workspace runtime services.\n" + : "Started execution workspace runtime services.\n", + metadata: { + runtimeServiceCount, + }, + }; + }, + }); + + const workspace = await svc.getById(id); + if (!workspace) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } + + await logActivity(db, { + companyId: existing.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: `execution_workspace.runtime_${action}`, + entityType: "execution_workspace", + entityId: existing.id, + details: { + runtimeServiceCount, + }, + }); + + res.json({ + workspace, + operation, + }); + }); + router.patch("/execution-workspaces/:id", validate(updateExecutionWorkspaceSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); @@ -52,25 +249,43 @@ export function executionWorkspaceRoutes(db: Db) { } assertCompanyAccess(req, existing.companyId); const patch: Record = { - ...req.body, - ...(req.body.cleanupEligibleAt ? { cleanupEligibleAt: new Date(req.body.cleanupEligibleAt) } : {}), + ...(req.body.name === undefined ? {} : { name: req.body.name }), + ...(req.body.cwd === undefined ? {} : { cwd: req.body.cwd }), + ...(req.body.repoUrl === undefined ? {} : { repoUrl: req.body.repoUrl }), + ...(req.body.baseRef === undefined ? {} : { baseRef: req.body.baseRef }), + ...(req.body.branchName === undefined ? {} : { branchName: req.body.branchName }), + ...(req.body.providerRef === undefined ? {} : { providerRef: req.body.providerRef }), + ...(req.body.status === undefined ? {} : { status: req.body.status }), + ...(req.body.cleanupReason === undefined ? {} : { cleanupReason: req.body.cleanupReason }), + ...(req.body.cleanupEligibleAt !== undefined + ? { cleanupEligibleAt: req.body.cleanupEligibleAt ? new Date(req.body.cleanupEligibleAt) : null } + : {}), }; + if (req.body.metadata !== undefined || req.body.config !== undefined) { + const requestedMetadata = req.body.metadata === undefined + ? (existing.metadata as Record | null) + : (req.body.metadata as Record | null); + patch.metadata = req.body.config === undefined + ? requestedMetadata + : mergeExecutionWorkspaceConfig(requestedMetadata, req.body.config ?? null); + } let workspace = existing; let cleanupWarnings: string[] = []; + const configForCleanup = readExecutionWorkspaceConfig( + ((patch.metadata as Record | null | undefined) ?? (existing.metadata as Record | null)) ?? null, + ); if (req.body.status === "archived" && existing.status !== "archived") { - const linkedIssues = await db - .select({ - id: issues.id, - status: issues.status, - }) - .from(issues) - .where(and(eq(issues.companyId, existing.companyId), eq(issues.executionWorkspaceId, existing.id))); - const activeLinkedIssues = linkedIssues.filter((issue) => !TERMINAL_ISSUE_STATUSES.has(issue.status)); + const readiness = await svc.getCloseReadiness(existing.id); + if (!readiness) { + res.status(404).json({ error: "Execution workspace not found" }); + return; + } - if (activeLinkedIssues.length > 0) { + if (readiness.state === "blocked") { res.status(409).json({ - error: `Cannot archive execution workspace while ${activeLinkedIssues.length} linked issue(s) are still open`, + error: readiness.blockingReasons[0] ?? "Execution workspace cannot be closed right now", + closeReadiness: readiness, }); return; } @@ -88,6 +303,21 @@ export function executionWorkspaceRoutes(db: Db) { } workspace = archivedWorkspace; + if (existing.mode === "shared_workspace") { + await db + .update(issues) + .set({ + executionWorkspaceId: null, + updatedAt: new Date(), + }) + .where( + and( + eq(issues.companyId, existing.companyId), + eq(issues.executionWorkspaceId, existing.id), + ), + ); + } + try { await stopRuntimeServicesForExecutionWorkspace({ db, @@ -101,7 +331,7 @@ export function executionWorkspaceRoutes(db: Db) { cleanupCommand: projectWorkspaces.cleanupCommand, }) .from(projectWorkspaces) - .where( + .where( and( eq(projectWorkspaces.id, existing.projectWorkspaceId), eq(projectWorkspaces.companyId, existing.companyId), @@ -121,7 +351,8 @@ export function executionWorkspaceRoutes(db: Db) { const cleanupResult = await cleanupExecutionWorkspaceArtifacts({ workspace: existing, projectWorkspace, - teardownCommand: projectPolicy?.workspaceStrategy?.teardownCommand ?? null, + teardownCommand: configForCleanup?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null, + cleanupCommand: configForCleanup?.cleanupCommand ?? null, recorder: workspaceOperationsSvc.createRecorder({ companyId: existing.companyId, executionWorkspaceId: existing.id, diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 0bf6e92f..795eb9a5 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -29,6 +29,17 @@ export function healthRoutes( return; } + try { + await db.execute(sql`SELECT 1`); + } catch { + res.status(503).json({ + status: "unhealthy", + version: serverVersion, + error: "database_unreachable", + }); + return; + } + let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; let bootstrapInviteActive = false; if (opts.deploymentMode === "authenticated") { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 744be227..d07de42a 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express"; import multer from "multer"; +import { z } from "zod"; import type { Db } from "@paperclipai/db"; import { addIssueCommentSchema, @@ -39,6 +40,9 @@ import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types. import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; const MAX_ISSUE_COMMENT_LIMIT = 500; +const updateIssueRouteSchema = updateIssueSchema.extend({ + interrupt: z.boolean().optional(), +}); export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); @@ -162,6 +166,30 @@ export function issueRoutes(db: Db, storage: StorageService) { return true; } + async function resolveActiveIssueRun(issue: { + id: string; + assigneeAgentId: string | null; + executionRunId?: string | null; + }) { + let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null; + + if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) { + const activeRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId); + const activeIssueId = + activeRun && + activeRun.contextSnapshot && + typeof activeRun.contextSnapshot === "object" && + typeof (activeRun.contextSnapshot as Record).issueId === "string" + ? ((activeRun.contextSnapshot as Record).issueId as string) + : null; + if (activeRun && activeRun.status === "running" && activeIssueId === issue.id) { + runToInterrupt = activeRun; + } + } + + return runToInterrupt?.status === "running" ? runToInterrupt : null; + } + async function normalizeIssueIdentifier(rawId: string): Promise { if (/^[A-Z]+-\d+$/i.test(rawId)) { const issue = await svc.getByIdentifier(rawId); @@ -231,6 +259,7 @@ export function issueRoutes(db: Db, storage: StorageService) { assertCompanyAccess(req, companyId); const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined; const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined; + const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined; const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined; const assigneeUserId = assigneeUserFilterRaw === "me" && req.actor.type === "board" @@ -240,6 +269,10 @@ export function issueRoutes(db: Db, storage: StorageService) { touchedByUserFilterRaw === "me" && req.actor.type === "board" ? req.actor.userId : touchedByUserFilterRaw; + const inboxArchivedByUserId = + inboxArchivedByUserFilterRaw === "me" && req.actor.type === "board" + ? req.actor.userId + : inboxArchivedByUserFilterRaw; const unreadForUserId = unreadForUserFilterRaw === "me" && req.actor.type === "board" ? req.actor.userId @@ -253,6 +286,10 @@ export function issueRoutes(db: Db, storage: StorageService) { res.status(403).json({ error: "touchedByUserId=me requires board authentication" }); return; } + if (inboxArchivedByUserFilterRaw === "me" && (!inboxArchivedByUserId || req.actor.type !== "board")) { + res.status(403).json({ error: "inboxArchivedByUserId=me requires board authentication" }); + return; + } if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) { res.status(403).json({ error: "unreadForUserId=me requires board authentication" }); return; @@ -264,8 +301,10 @@ export function issueRoutes(db: Db, storage: StorageService) { participantAgentId: req.query.participantAgentId as string | undefined, assigneeUserId, touchedByUserId, + inboxArchivedByUserId, unreadForUserId, projectId: req.query.projectId as string | undefined, + executionWorkspaceId: req.query.executionWorkspaceId as string | undefined, parentId: req.query.parentId as string | undefined, labelId: req.query.labelId as string | undefined, originKind: req.query.originKind as string | undefined, @@ -755,6 +794,102 @@ export function issueRoutes(db: Db, storage: StorageService) { res.json(readState); }); + router.delete("/issues/:id/read", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + const removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.read_unmarked", + entityType: "issue", + entityId: issue.id, + details: { userId: req.actor.userId }, + }); + res.json({ id: issue.id, removed }); + }); + + router.post("/issues/:id/inbox-archive", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + const archiveState = await svc.archiveInbox(issue.companyId, issue.id, req.actor.userId, new Date()); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.inbox_archived", + entityType: "issue", + entityId: issue.id, + details: { userId: req.actor.userId, archivedAt: archiveState.archivedAt }, + }); + res.json(archiveState); + }); + + router.delete("/issues/:id/inbox-archive", async (req, res) => { + const id = req.params.id as string; + const issue = await svc.getById(id); + if (!issue) { + res.status(404).json({ error: "Issue not found" }); + return; + } + assertCompanyAccess(req, issue.companyId); + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + if (!req.actor.userId) { + res.status(403).json({ error: "Board user context required" }); + return; + } + const removed = await svc.unarchiveInbox(issue.companyId, issue.id, req.actor.userId); + const actor = getActorInfo(req); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "issue.inbox_unarchived", + entityType: "issue", + entityId: issue.id, + details: { userId: req.actor.userId }, + }); + res.json(removed ?? { ok: true }); + }); + router.get("/issues/:id/approvals", async (req, res) => { const id = req.params.id as string; const issue = await svc.getById(id); @@ -865,7 +1000,7 @@ export function issueRoutes(db: Db, storage: StorageService) { res.status(201).json(issue); }); - router.patch("/issues/:id", validate(updateIssueSchema), async (req, res) => { + router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => { const id = req.params.id as string; const existing = await svc.getById(id); if (!existing) { @@ -895,7 +1030,45 @@ export function issueRoutes(db: Db, storage: StorageService) { const actor = getActorInfo(req); const isClosed = existing.status === "done" || existing.status === "cancelled"; - const { comment: commentBody, reopen: reopenRequested, hiddenAt: hiddenAtRaw, ...updateFields } = req.body; + const { + comment: commentBody, + reopen: reopenRequested, + interrupt: interruptRequested, + hiddenAt: hiddenAtRaw, + ...updateFields + } = req.body; + let interruptedRunId: string | null = null; + + if (interruptRequested) { + if (!commentBody) { + res.status(400).json({ error: "Interrupt is only supported when posting a comment" }); + return; + } + if (req.actor.type !== "board") { + res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" }); + return; + } + + const runToInterrupt = await resolveActiveIssueRun(existing); + if (runToInterrupt) { + const cancelled = await heartbeat.cancelRun(runToInterrupt.id); + if (cancelled) { + interruptedRunId = cancelled.id; + await logActivity(db, { + companyId: cancelled.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id }, + }); + } + } + } + if (hiddenAtRaw !== undefined) { updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null; } @@ -970,6 +1143,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, ...(commentBody ? { source: "comment" } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), _previous: hasFieldChanges ? previous : undefined, }, }); @@ -996,6 +1170,7 @@ export function issueRoutes(db: Db, storage: StorageService) { identifier: issue.identifier, issueTitle: issue.title, ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), ...(hasFieldChanges ? { updated: true } : {}), }, }); @@ -1017,10 +1192,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "assignment", triggerDetail: "system", reason: "issue_assigned", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.update" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1029,10 +1212,18 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "automation", triggerDetail: "system", reason: "issue_status_changed", - payload: { issueId: issue.id, mutation: "update" }, + payload: { + issueId: issue.id, + mutation: "update", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, - contextSnapshot: { issueId: issue.id, source: "issue.status_change" }, + contextSnapshot: { + issueId: issue.id, + source: "issue.status_change", + ...(interruptedRunId ? { interruptedRunId } : {}), + }, }); } @@ -1325,28 +1516,8 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } - let runToInterrupt = currentIssue.executionRunId - ? await heartbeat.getRun(currentIssue.executionRunId) - : null; - - if ( - (!runToInterrupt || runToInterrupt.status !== "running") && - currentIssue.assigneeAgentId - ) { - const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId); - const activeIssueId = - activeRun && - activeRun.contextSnapshot && - typeof activeRun.contextSnapshot === "object" && - typeof (activeRun.contextSnapshot as Record).issueId === "string" - ? ((activeRun.contextSnapshot as Record).issueId as string) - : null; - if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) { - runToInterrupt = activeRun; - } - } - - if (runToInterrupt && runToInterrupt.status === "running") { + const runToInterrupt = await resolveActiveIssueRun(currentIssue); + if (runToInterrupt) { const cancelled = await heartbeat.cancelRun(runToInterrupt.id); if (cancelled) { interruptedRunId = cancelled.id; diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 51555ff5..b200b354 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -8,13 +8,15 @@ import { updateProjectWorkspaceSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { projectService, logActivity } from "../services/index.js"; +import { projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; +import { startRuntimeServicesForWorkspaceControl, stopRuntimeServicesForProjectWorkspace } from "../services/workspace-runtime.js"; export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); + const workspaceOperations = workspaceOperationService(db); async function resolveCompanyIdForProjectReference(req: Request) { const companyIdQuery = req.query.companyId; @@ -229,6 +231,145 @@ export function projectRoutes(db: Db) { }, ); + router.post("/projects/:id/workspaces/:workspaceId/runtime-services/:action", async (req, res) => { + const id = req.params.id as string; + const workspaceId = req.params.workspaceId as string; + const action = String(req.params.action ?? "").trim().toLowerCase(); + if (action !== "start" && action !== "stop" && action !== "restart") { + res.status(404).json({ error: "Runtime service action not found" }); + return; + } + + const project = await svc.getById(id); + if (!project) { + res.status(404).json({ error: "Project not found" }); + return; + } + assertCompanyAccess(req, project.companyId); + + const workspace = project.workspaces.find((entry) => entry.id === workspaceId) ?? null; + if (!workspace) { + res.status(404).json({ error: "Project workspace not found" }); + return; + } + + const workspaceCwd = workspace.cwd; + if (!workspaceCwd) { + res.status(422).json({ error: "Project workspace needs a local path before Paperclip can manage local runtime services" }); + return; + } + + const runtimeConfig = workspace.runtimeConfig?.workspaceRuntime ?? null; + if ((action === "start" || action === "restart") && !runtimeConfig) { + res.status(422).json({ error: "Project workspace has no runtime service configuration" }); + return; + } + + const actor = getActorInfo(req); + const recorder = workspaceOperations.createRecorder({ companyId: project.companyId }); + let runtimeServiceCount = workspace.runtimeServices?.length ?? 0; + const stdout: string[] = []; + const stderr: string[] = []; + + const operation = await recorder.recordOperation({ + phase: action === "stop" ? "workspace_teardown" : "workspace_provision", + command: `workspace runtime ${action}`, + cwd: workspace.cwd, + metadata: { + action, + projectId: project.id, + projectWorkspaceId: workspace.id, + }, + run: async () => { + const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + if (stream === "stdout") stdout.push(chunk); + else stderr.push(chunk); + }; + + if (action === "stop" || action === "restart") { + await stopRuntimeServicesForProjectWorkspace({ + db, + projectWorkspaceId: workspace.id, + }); + } + + if (action === "start" || action === "restart") { + const startedServices = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { + id: actor.agentId ?? null, + name: actor.actorType === "user" ? "Board" : "Agent", + companyId: project.companyId, + }, + issue: null, + workspace: { + baseCwd: workspaceCwd, + source: "project_primary", + projectId: project.id, + workspaceId: workspace.id, + repoUrl: workspace.repoUrl, + repoRef: workspace.repoRef, + strategy: "project_primary", + cwd: workspaceCwd, + branchName: workspace.defaultRef ?? workspace.repoRef ?? null, + worktreePath: null, + warnings: [], + created: false, + }, + config: { workspaceRuntime: runtimeConfig }, + adapterEnv: {}, + onLog, + }); + runtimeServiceCount = startedServices.length; + } else { + runtimeServiceCount = 0; + } + + await svc.updateWorkspace(project.id, workspace.id, { + runtimeConfig: { + desiredState: action === "stop" ? "stopped" : "running", + }, + }); + + return { + status: "succeeded", + stdout: stdout.join(""), + stderr: stderr.join(""), + system: + action === "stop" + ? "Stopped project workspace runtime services.\n" + : action === "restart" + ? "Restarted project workspace runtime services.\n" + : "Started project workspace runtime services.\n", + metadata: { + runtimeServiceCount, + }, + }; + }, + }); + + const updatedWorkspace = (await svc.listWorkspaces(project.id)).find((entry) => entry.id === workspace.id) ?? workspace; + + await logActivity(db, { + companyId: project.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + action: `project.workspace_runtime_${action}`, + entityType: "project", + entityId: project.id, + details: { + projectWorkspaceId: workspace.id, + runtimeServiceCount, + }, + }); + + res.json({ + workspace: updatedWorkspace, + operation, + }); + }); + router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => { const id = req.params.id as string; const workspaceId = req.params.workspaceId as string; diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 231ed839..6dcbb38f 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -9,6 +9,7 @@ const ROOT_KEY = "instructionsRootPath"; const ENTRY_KEY = "instructionsEntryFile"; const FILE_KEY = "instructionsFilePath"; const PROMPT_KEY = "promptTemplate"; +/** @deprecated Use the managed instructions bundle system instead. */ const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate"; const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md"; const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 7cfe8ffa..db4be18a 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1475,7 +1475,7 @@ function normalizePortableConfig( key === "instructionsRootPath" || key === "instructionsEntryFile" || key === "promptTemplate" || - key === "bootstrapPromptTemplate" || + key === "bootstrapPromptTemplate" || // deprecated — kept for backward compat key === "paperclipSkillSync" ) continue; if (key === "env") continue; @@ -3895,7 +3895,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { desiredSkills, ); delete adapterConfigWithSkills.promptTemplate; - delete adapterConfigWithSkills.bootstrapPromptTemplate; + delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated delete adapterConfigWithSkills.instructionsFilePath; delete adapterConfigWithSkills.instructionsBundleMode; delete adapterConfigWithSkills.instructionsRootPath; diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index ea4dd163..a1d3b41d 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -1,11 +1,292 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; import { and, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { executionWorkspaces } from "@paperclipai/db"; -import type { ExecutionWorkspace } from "@paperclipai/shared"; +import { executionWorkspaces, issues, projects, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; +import type { + ExecutionWorkspace, + ExecutionWorkspaceCloseAction, + ExecutionWorkspaceCloseGitReadiness, + ExecutionWorkspaceCloseReadiness, + ExecutionWorkspaceConfig, + WorkspaceRuntimeService, +} from "@paperclipai/shared"; +import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; +type WorkspaceRuntimeServiceRow = typeof workspaceRuntimeServices.$inferSelect; +const execFileAsync = promisify(execFile); +const TERMINAL_ISSUE_STATUSES = new Set(["done", "cancelled"]); -function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace { +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNullableString(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function cloneRecord(value: unknown): Record | null { + if (!isRecord(value)) return null; + return { ...value }; +} + +async function pathExists(value: string | null | undefined) { + if (!value) return false; + try { + await fs.access(value); + return true; + } catch { + return false; + } +} + +async function runGit(args: string[], cwd: string) { + return await execFileAsync("git", ["-C", cwd, ...args], { cwd }); +} + +async function inspectGitCloseReadiness(workspace: ExecutionWorkspace): Promise<{ + git: ExecutionWorkspaceCloseGitReadiness | null; + warnings: string[]; +}> { + const warnings: string[] = []; + const workspacePath = readNullableString(workspace.providerRef) ?? readNullableString(workspace.cwd); + const createdByRuntime = workspace.metadata?.createdByRuntime === true; + const expectsGitInspection = + workspace.providerType === "git_worktree" || + Boolean(workspace.repoUrl || workspace.baseRef || workspace.branchName || workspacePath); + + if (!expectsGitInspection) { + return { git: null, warnings }; + } + + if (!workspacePath) { + warnings.push("Workspace has no local path, so Paperclip cannot inspect git status before close."); + return { git: null, warnings }; + } + + if (!(await pathExists(workspacePath))) { + warnings.push(`Workspace path "${workspacePath}" does not exist, so Paperclip cannot inspect git status before close.`); + return { + git: { + repoRoot: null, + workspacePath, + branchName: workspace.branchName, + baseRef: workspace.baseRef, + hasDirtyTrackedFiles: false, + hasUntrackedFiles: false, + dirtyEntryCount: 0, + untrackedEntryCount: 0, + aheadCount: null, + behindCount: null, + isMergedIntoBase: null, + createdByRuntime, + }, + warnings, + }; + } + + let repoRoot: string | null = null; + try { + repoRoot = (await runGit(["rev-parse", "--show-toplevel"], workspacePath)).stdout.trim() || null; + } catch (error) { + warnings.push( + `Could not inspect git status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + + let branchName = workspace.branchName; + if (repoRoot && !branchName) { + try { + branchName = (await runGit(["rev-parse", "--abbrev-ref", "HEAD"], workspacePath)).stdout.trim() || null; + } catch { + branchName = workspace.branchName; + } + } + + let dirtyEntryCount = 0; + let untrackedEntryCount = 0; + if (repoRoot) { + try { + const statusOutput = (await runGit(["status", "--porcelain=v1", "--untracked-files=all"], workspacePath)).stdout; + for (const line of statusOutput.split(/\r?\n/)) { + if (!line) continue; + if (line.startsWith("??")) { + untrackedEntryCount += 1; + continue; + } + dirtyEntryCount += 1; + } + } catch (error) { + warnings.push( + `Could not read git working tree status for "${workspacePath}": ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + let aheadCount: number | null = null; + let behindCount: number | null = null; + let isMergedIntoBase: boolean | null = null; + const baseRef = workspace.baseRef; + + if (repoRoot && baseRef) { + try { + const counts = (await runGit(["rev-list", "--left-right", "--count", `${baseRef}...HEAD`], workspacePath)).stdout.trim(); + const [behindRaw, aheadRaw] = counts.split(/\s+/); + behindCount = behindRaw ? Number.parseInt(behindRaw, 10) : 0; + aheadCount = aheadRaw ? Number.parseInt(aheadRaw, 10) : 0; + } catch (error) { + warnings.push( + `Could not compare this workspace against ${baseRef}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + try { + await runGit(["merge-base", "--is-ancestor", "HEAD", baseRef], workspacePath); + isMergedIntoBase = true; + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : null; + if (code === 1) isMergedIntoBase = false; + else { + warnings.push( + `Could not determine whether this workspace is merged into ${baseRef}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + return { + git: { + repoRoot, + workspacePath, + branchName, + baseRef, + hasDirtyTrackedFiles: dirtyEntryCount > 0, + hasUntrackedFiles: untrackedEntryCount > 0, + dirtyEntryCount, + untrackedEntryCount, + aheadCount, + behindCount, + isMergedIntoBase, + createdByRuntime, + }, + warnings, + }; +} + +export function readExecutionWorkspaceConfig(metadata: Record | null | undefined): ExecutionWorkspaceConfig | null { + const raw = isRecord(metadata?.config) ? metadata.config : null; + if (!raw) return null; + + const config: ExecutionWorkspaceConfig = { + provisionCommand: readNullableString(raw.provisionCommand), + teardownCommand: readNullableString(raw.teardownCommand), + cleanupCommand: readNullableString(raw.cleanupCommand), + workspaceRuntime: cloneRecord(raw.workspaceRuntime), + desiredState: raw.desiredState === "running" || raw.desiredState === "stopped" ? raw.desiredState : null, + }; + + const hasConfig = Object.values(config).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + + return hasConfig ? config : null; +} + +export function mergeExecutionWorkspaceConfig( + metadata: Record | null | undefined, + patch: Partial | null, +): Record | null { + const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; + const current = readExecutionWorkspaceConfig(metadata) ?? { + provisionCommand: null, + teardownCommand: null, + cleanupCommand: null, + workspaceRuntime: null, + desiredState: null, + }; + + if (patch === null) { + delete nextMetadata.config; + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; + } + + const nextConfig: ExecutionWorkspaceConfig = { + provisionCommand: patch.provisionCommand !== undefined ? readNullableString(patch.provisionCommand) : current.provisionCommand, + teardownCommand: patch.teardownCommand !== undefined ? readNullableString(patch.teardownCommand) : current.teardownCommand, + cleanupCommand: patch.cleanupCommand !== undefined ? readNullableString(patch.cleanupCommand) : current.cleanupCommand, + workspaceRuntime: patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, + desiredState: + patch.desiredState !== undefined + ? patch.desiredState === "running" || patch.desiredState === "stopped" + ? patch.desiredState + : null + : current.desiredState, + }; + + const hasConfig = Object.values(nextConfig).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + + if (hasConfig) { + nextMetadata.config = { + provisionCommand: nextConfig.provisionCommand, + teardownCommand: nextConfig.teardownCommand, + cleanupCommand: nextConfig.cleanupCommand, + workspaceRuntime: nextConfig.workspaceRuntime, + desiredState: nextConfig.desiredState, + }; + } else { + delete nextMetadata.config; + } + + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; +} + +function toRuntimeService(row: WorkspaceRuntimeServiceRow): WorkspaceRuntimeService { + return { + id: row.id, + companyId: row.companyId, + projectId: row.projectId ?? null, + projectWorkspaceId: row.projectWorkspaceId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + issueId: row.issueId ?? null, + scopeType: row.scopeType as WorkspaceRuntimeService["scopeType"], + scopeId: row.scopeId ?? null, + serviceName: row.serviceName, + status: row.status as WorkspaceRuntimeService["status"], + lifecycle: row.lifecycle as WorkspaceRuntimeService["lifecycle"], + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: row.port ?? null, + url: row.url ?? null, + provider: row.provider as WorkspaceRuntimeService["provider"], + providerRef: row.providerRef ?? null, + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: row.lastUsedAt, + startedAt: row.startedAt, + stoppedAt: row.stoppedAt ?? null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: row.healthStatus as WorkspaceRuntimeService["healthStatus"], + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +function toExecutionWorkspace( + row: ExecutionWorkspaceRow, + runtimeServices: WorkspaceRuntimeService[] = [], +): ExecutionWorkspace { return { id: row.id, companyId: row.companyId, @@ -28,7 +309,9 @@ function toExecutionWorkspace(row: ExecutionWorkspaceRow): ExecutionWorkspace { closedAt: row.closedAt ?? null, cleanupEligibleAt: row.cleanupEligibleAt ?? null, cleanupReason: row.cleanupReason ?? null, + config: readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null), metadata: (row.metadata as Record | null) ?? null, + runtimeServices, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -63,7 +346,7 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(and(...conditions)) .orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)); - return rows.map(toExecutionWorkspace); + return rows.map((row) => toExecutionWorkspace(row)); }, getById: async (id: string) => { @@ -72,7 +355,268 @@ export function executionWorkspaceService(db: Db) { .from(executionWorkspaces) .where(eq(executionWorkspaces.id, id)) .then((rows) => rows[0] ?? null); - return row ? toExecutionWorkspace(row) : null; + if (!row) return null; + const runtimeServiceRows = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.executionWorkspaceId, row.id)) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + return toExecutionWorkspace(row, runtimeServiceRows.map(toRuntimeService)); + }, + + getCloseReadiness: async (id: string): Promise => { + const workspace = await db + .select() + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, id)) + .then((rows) => rows[0] ?? null); + if (!workspace) return null; + + const runtimeServiceRows = await db + .select() + .from(workspaceRuntimeServices) + .where(eq(workspaceRuntimeServices.executionWorkspaceId, workspace.id)) + .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); + const runtimeServices = runtimeServiceRows.map(toRuntimeService); + + const linkedIssues = await db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.companyId, workspace.companyId), eq(issues.executionWorkspaceId, workspace.id))); + + const projectWorkspace = workspace.projectWorkspaceId + ? await db + .select({ + id: projectWorkspaces.id, + cwd: projectWorkspaces.cwd, + cleanupCommand: projectWorkspaces.cleanupCommand, + isPrimary: projectWorkspaces.isPrimary, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.id, workspace.projectWorkspaceId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + const primaryProjectWorkspace = workspace.projectId + ? await db + .select({ + id: projectWorkspaces.id, + }) + .from(projectWorkspaces) + .where( + and( + eq(projectWorkspaces.companyId, workspace.companyId), + eq(projectWorkspaces.projectId, workspace.projectId), + eq(projectWorkspaces.isPrimary, true), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + const projectPolicy = workspace.projectId + ? await db + .select({ + executionWorkspacePolicy: projects.executionWorkspacePolicy, + }) + .from(projects) + .where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, workspace.companyId))) + .then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)) + : null; + + const executionWorkspace = toExecutionWorkspace(workspace, runtimeServices); + const config = readExecutionWorkspaceConfig((workspace.metadata as Record | null) ?? null); + const { git, warnings: gitWarnings } = await inspectGitCloseReadiness(executionWorkspace); + const warnings = [...gitWarnings]; + const blockingReasons: string[] = []; + const isSharedWorkspace = executionWorkspace.mode === "shared_workspace"; + const workspacePath = readNullableString(executionWorkspace.providerRef) ?? readNullableString(executionWorkspace.cwd); + const resolvedWorkspacePath = workspacePath ? path.resolve(workspacePath) : null; + const resolvedPrimaryWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null; + const isProjectPrimaryWorkspace = + workspace.projectWorkspaceId != null + && workspace.projectWorkspaceId === primaryProjectWorkspace?.id + && resolvedWorkspacePath != null + && resolvedPrimaryWorkspacePath != null + && resolvedWorkspacePath === resolvedPrimaryWorkspacePath; + + const linkedIssueSummaries = linkedIssues.map((issue) => ({ + ...issue, + isTerminal: TERMINAL_ISSUE_STATUSES.has(issue.status), + })); + + const blockingIssues = linkedIssueSummaries.filter((issue) => !issue.isTerminal); + if (blockingIssues.length > 0) { + const linkedIssueMessage = + blockingIssues.length === 1 + ? "This workspace is still linked to an open issue." + : `This workspace is still linked to ${blockingIssues.length} open issues.`; + if (isSharedWorkspace) { + warnings.push(`${linkedIssueMessage} Archiving it will detach this shared workspace session from those issues, but keep the underlying project workspace available.`); + } else { + blockingReasons.push(linkedIssueMessage); + } + } + + if (isSharedWorkspace) { + warnings.push("This shared workspace session points at project workspace infrastructure. Archiving it only removes the session record."); + } + + if (runtimeServices.some((service) => service.status !== "stopped")) { + warnings.push( + runtimeServices.length === 1 + ? "Closing this workspace will stop 1 attached runtime service." + : `Closing this workspace will stop ${runtimeServices.length} attached runtime services.`, + ); + } + + if (git?.hasDirtyTrackedFiles) { + warnings.push( + git.dirtyEntryCount === 1 + ? "The workspace has 1 modified tracked file." + : `The workspace has ${git.dirtyEntryCount} modified tracked files.`, + ); + } + if (git?.hasUntrackedFiles) { + warnings.push( + git.untrackedEntryCount === 1 + ? "The workspace has 1 untracked file." + : `The workspace has ${git.untrackedEntryCount} untracked files.`, + ); + } + if (git?.aheadCount && git.aheadCount > 0 && git.isMergedIntoBase === false) { + warnings.push( + git.aheadCount === 1 + ? `This workspace is 1 commit ahead of ${git.baseRef ?? "the base ref"} and is not merged.` + : `This workspace is ${git.aheadCount} commits ahead of ${git.baseRef ?? "the base ref"} and is not merged.`, + ); + } + if (git?.behindCount && git.behindCount > 0) { + warnings.push( + git.behindCount === 1 + ? `This workspace is 1 commit behind ${git.baseRef ?? "the base ref"}.` + : `This workspace is ${git.behindCount} commits behind ${git.baseRef ?? "the base ref"}.`, + ); + } + + const plannedActions: ExecutionWorkspaceCloseAction[] = [ + { + kind: "archive_record", + label: "Archive workspace record", + description: "Keep the execution workspace history and issue linkage, but remove it from active workspace lists.", + command: null, + }, + ]; + + if (runtimeServices.some((service) => service.status !== "stopped")) { + plannedActions.push({ + kind: "stop_runtime_services", + label: runtimeServices.length === 1 ? "Stop attached runtime service" : "Stop attached runtime services", + description: + runtimeServices.length === 1 + ? `${runtimeServices[0]?.serviceName ?? "A runtime service"} will be stopped before cleanup.` + : `${runtimeServices.length} runtime services will be stopped before cleanup.`, + command: null, + }); + } + + const configuredCleanupCommands = [ + { + kind: "cleanup_command" as const, + label: "Run workspace cleanup command", + description: "Workspace-specific cleanup runs before teardown.", + command: config?.cleanupCommand ?? null, + }, + { + kind: "cleanup_command" as const, + label: "Run project workspace cleanup command", + description: "Project workspace cleanup runs before execution workspace teardown.", + command: projectWorkspace?.cleanupCommand ?? null, + }, + ]; + for (const action of configuredCleanupCommands) { + if (!action.command) continue; + plannedActions.push(action); + } + + const teardownCommand = config?.teardownCommand ?? projectPolicy?.workspaceStrategy?.teardownCommand ?? null; + if (teardownCommand) { + plannedActions.push({ + kind: "teardown_command", + label: "Run teardown command", + description: "Teardown runs after cleanup commands during workspace close.", + command: teardownCommand, + }); + } + + if (executionWorkspace.providerType === "git_worktree" && workspacePath) { + plannedActions.push({ + kind: "git_worktree_remove", + label: "Remove git worktree", + description: `Paperclip will run git worktree cleanup for ${workspacePath}.`, + command: `git worktree remove --force ${workspacePath}`, + }); + } + + if (git?.createdByRuntime && executionWorkspace.branchName) { + plannedActions.push({ + kind: "git_branch_delete", + label: "Delete runtime-created branch", + description: "Paperclip will try to delete the runtime-created branch after removing the worktree.", + command: `git branch -d ${executionWorkspace.branchName}`, + }); + } + + if (executionWorkspace.providerType === "local_fs" && git?.createdByRuntime && workspacePath) { + const resolvedWorkspacePath = path.resolve(workspacePath); + const resolvedProjectWorkspacePath = projectWorkspace?.cwd ? path.resolve(projectWorkspace.cwd) : null; + const containsProjectWorkspace = resolvedProjectWorkspacePath + ? ( + resolvedWorkspacePath === resolvedProjectWorkspacePath || + resolvedProjectWorkspacePath.startsWith(`${resolvedWorkspacePath}${path.sep}`) + ) + : false; + if (containsProjectWorkspace) { + warnings.push(`Paperclip will archive this workspace but keep "${workspacePath}" because it contains the project workspace.`); + } else { + plannedActions.push({ + kind: "remove_local_directory", + label: "Remove runtime-created directory", + description: `Paperclip will remove the runtime-created directory at ${workspacePath}.`, + command: `rm -rf ${workspacePath}`, + }); + } + } + + const state = + blockingReasons.length > 0 + ? "blocked" + : warnings.length > 0 + ? "ready_with_warnings" + : "ready"; + + return { + workspaceId: workspace.id, + state, + blockingReasons, + warnings, + linkedIssues: linkedIssueSummaries, + plannedActions, + isDestructiveCloseAllowed: blockingReasons.length === 0, + isSharedWorkspace, + isProjectPrimaryWorkspace, + git, + runtimeServices, + }; }, create: async (data: typeof executionWorkspaces.$inferInsert) => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c909b9b7..168db86b 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -4,7 +4,7 @@ import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; import { and, asc, desc, eq, gt, inArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import type { BillingType } from "@paperclipai/shared"; +import type { BillingType, ExecutionWorkspace, ExecutionWorkspaceConfig } from "@paperclipai/shared"; import { agents, agentRuntimeState, @@ -37,10 +37,12 @@ import { persistAdapterManagedRuntimeServices, realizeExecutionWorkspace, releaseRuntimeServicesForRun, + type ExecutionWorkspaceInput, + type RealizedExecutionWorkspace, sanitizeRuntimeServiceBaseEnv, } from "./workspace-runtime.js"; import { issueService } from "./issues.js"; -import { executionWorkspaceService } from "./execution-workspaces.js"; +import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, @@ -76,6 +78,87 @@ const SESSIONED_LOCAL_ADAPTERS = new Set([ "pi_local", ]); +export function applyPersistedExecutionWorkspaceConfig(input: { + config: Record; + workspaceConfig: ExecutionWorkspaceConfig | null; + mode: ReturnType; +}) { + const nextConfig = { ...input.config }; + + if (input.mode !== "agent_default") { + if (input.workspaceConfig?.workspaceRuntime === null) { + delete nextConfig.workspaceRuntime; + } else if (input.workspaceConfig?.workspaceRuntime) { + nextConfig.workspaceRuntime = { ...input.workspaceConfig.workspaceRuntime }; + } + } + + if (input.workspaceConfig && input.mode === "isolated_workspace") { + const nextStrategy = parseObject(nextConfig.workspaceStrategy); + if (input.workspaceConfig.provisionCommand === null) delete nextStrategy.provisionCommand; + else nextStrategy.provisionCommand = input.workspaceConfig.provisionCommand; + if (input.workspaceConfig.teardownCommand === null) delete nextStrategy.teardownCommand; + else nextStrategy.teardownCommand = input.workspaceConfig.teardownCommand; + nextConfig.workspaceStrategy = nextStrategy; + } + + return nextConfig; +} + +export function stripWorkspaceRuntimeFromExecutionRunConfig(config: Record) { + const nextConfig = { ...config }; + delete nextConfig.workspaceRuntime; + return nextConfig; +} + +export function buildRealizedExecutionWorkspaceFromPersisted(input: { + base: ExecutionWorkspaceInput; + workspace: ExecutionWorkspace; +}): RealizedExecutionWorkspace | null { + const cwd = readNonEmptyString(input.workspace.cwd) ?? readNonEmptyString(input.workspace.providerRef); + if (!cwd) { + return null; + } + + const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary"; + return { + baseCwd: input.base.baseCwd, + source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: input.workspace.projectId ?? input.base.projectId, + workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId, + repoUrl: input.workspace.repoUrl ?? input.base.repoUrl, + repoRef: input.workspace.baseRef ?? input.base.repoRef, + strategy, + cwd, + branchName: input.workspace.branchName ?? null, + worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null, + warnings: [], + created: false, + }; +} + +function buildExecutionWorkspaceConfigSnapshot(config: Record): Partial | null { + const strategy = parseObject(config.workspaceStrategy); + const snapshot: Partial = {}; + + if ("workspaceStrategy" in config) { + snapshot.provisionCommand = typeof strategy.provisionCommand === "string" ? strategy.provisionCommand : null; + snapshot.teardownCommand = typeof strategy.teardownCommand === "string" ? strategy.teardownCommand : null; + } + + if ("workspaceRuntime" in config) { + const workspaceRuntime = parseObject(config.workspaceRuntime); + snapshot.workspaceRuntime = Object.keys(workspaceRuntime).length > 0 ? workspaceRuntime : null; + } + + const hasSnapshot = Object.values(snapshot).some((value) => { + if (value === null) return false; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; + }); + return hasSnapshot ? snapshot : null; +} + function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { const trimmed = repoUrl?.trim() ?? ""; if (!trimmed) return null; @@ -2030,7 +2113,7 @@ export function heartbeatService(db: Db) { (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); const config = parseObject(agent.adapterConfig); - const executionWorkspaceMode = resolveExecutionWorkspaceMode({ + const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, @@ -2039,27 +2122,8 @@ export function heartbeatService(db: Db) { agent, context, previousSessionParams, - { useProjectWorkspace: executionWorkspaceMode !== "agent_default" }, + { useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" }, ); - const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({ - agentConfig: config, - projectPolicy: projectExecutionWorkspacePolicy, - issueSettings: issueExecutionWorkspaceSettings, - mode: executionWorkspaceMode, - legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, - }); - const mergedConfig = issueAssigneeOverrides?.adapterConfig - ? { ...workspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } - : workspaceManagedConfig; - const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( - agent.companyId, - mergedConfig, - ); - const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); - const runtimeConfig = { - ...resolvedConfig, - paperclipRuntimeSkills: runtimeSkillEntries, - }; const issueRef = issueContext ? { id: issueContext.id, @@ -2073,36 +2137,90 @@ export function heartbeatService(db: Db) { : null; const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; + const shouldReuseExisting = + issueRef?.executionWorkspacePreference === "reuse_existing" && + existingExecutionWorkspace && + existingExecutionWorkspace.status !== "archived"; + const persistedExecutionWorkspaceMode = shouldReuseExisting && existingExecutionWorkspace + ? issueExecutionWorkspaceModeForPersistedWorkspace(existingExecutionWorkspace.mode) + : null; + const effectiveExecutionWorkspaceMode: ReturnType = + persistedExecutionWorkspaceMode === "isolated_workspace" || + persistedExecutionWorkspaceMode === "operator_branch" || + persistedExecutionWorkspaceMode === "agent_default" + ? persistedExecutionWorkspaceMode + : requestedExecutionWorkspaceMode; + const workspaceManagedConfig = shouldReuseExisting + ? { ...config } + : buildExecutionWorkspaceAdapterConfig({ + agentConfig: config, + projectPolicy: projectExecutionWorkspacePolicy, + issueSettings: issueExecutionWorkspaceSettings, + mode: requestedExecutionWorkspaceMode, + legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null, + }); + const persistedWorkspaceManagedConfig = applyPersistedExecutionWorkspaceConfig({ + config: workspaceManagedConfig, + workspaceConfig: existingExecutionWorkspace?.config ?? null, + mode: effectiveExecutionWorkspaceMode, + }); + const mergedConfig = issueAssigneeOverrides?.adapterConfig + ? { ...persistedWorkspaceManagedConfig, ...issueAssigneeOverrides.adapterConfig } + : persistedWorkspaceManagedConfig; + const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig); + const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig); + const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( + agent.companyId, + executionRunConfig, + ); + const runtimeSkillEntries = await companySkills.listRuntimeSkillEntries(agent.companyId); + const runtimeConfig = { + ...resolvedConfig, + paperclipRuntimeSkills: runtimeSkillEntries, + }; const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, executionWorkspaceId: existingExecutionWorkspace?.id ?? null, }); - const executionWorkspace = await realizeExecutionWorkspace({ - base: { - baseCwd: resolvedWorkspace.cwd, - source: resolvedWorkspace.source, - projectId: resolvedWorkspace.projectId, - workspaceId: resolvedWorkspace.workspaceId, - repoUrl: resolvedWorkspace.repoUrl, - repoRef: resolvedWorkspace.repoRef, - }, - config: runtimeConfig, - issue: issueRef, - agent: { - id: agent.id, - name: agent.name, - companyId: agent.companyId, - }, - recorder: workspaceOperationRecorder, - }); + const executionWorkspaceBase = { + baseCwd: resolvedWorkspace.cwd, + source: resolvedWorkspace.source, + projectId: resolvedWorkspace.projectId, + workspaceId: resolvedWorkspace.workspaceId, + repoUrl: resolvedWorkspace.repoUrl, + repoRef: resolvedWorkspace.repoRef, + } satisfies ExecutionWorkspaceInput; + const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace + ? buildRealizedExecutionWorkspaceFromPersisted({ + base: executionWorkspaceBase, + workspace: existingExecutionWorkspace, + }) + : null; + const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({ + base: executionWorkspaceBase, + config: runtimeConfig, + issue: issueRef, + agent: { + id: agent.id, + name: agent.name, + companyId: agent.companyId, + }, + recorder: workspaceOperationRecorder, + }); const resolvedProjectId = executionWorkspace.projectId ?? issueRef?.projectId ?? executionProjectId ?? null; const resolvedProjectWorkspaceId = issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null; - const shouldReuseExisting = - issueRef?.executionWorkspacePreference === "reuse_existing" && - existingExecutionWorkspace && - existingExecutionWorkspace.status !== "archived"; let persistedExecutionWorkspace = null; + const nextExecutionWorkspaceMetadataBase = { + ...(existingExecutionWorkspace?.metadata ?? {}), + source: executionWorkspace.source, + createdByRuntime: executionWorkspace.created, + } as Record; + const nextExecutionWorkspaceMetadata = shouldReuseExisting + ? nextExecutionWorkspaceMetadataBase + : configSnapshot + ? mergeExecutionWorkspaceConfig(nextExecutionWorkspaceMetadataBase, configSnapshot) + : nextExecutionWorkspaceMetadataBase; try { persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace ? await executionWorkspacesSvc.update(existingExecutionWorkspace.id, { @@ -2114,11 +2232,7 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, status: "active", lastUsedAt: new Date(), - metadata: { - ...(existingExecutionWorkspace.metadata ?? {}), - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - }, + metadata: nextExecutionWorkspaceMetadata, }) : resolvedProjectId ? await executionWorkspacesSvc.create({ @@ -2127,11 +2241,11 @@ export function heartbeatService(db: Db) { projectWorkspaceId: resolvedProjectWorkspaceId, sourceIssueId: issueRef?.id ?? null, mode: - executionWorkspaceMode === "isolated_workspace" + requestedExecutionWorkspaceMode === "isolated_workspace" ? "isolated_workspace" - : executionWorkspaceMode === "operator_branch" + : requestedExecutionWorkspaceMode === "operator_branch" ? "operator_branch" - : executionWorkspaceMode === "agent_default" + : requestedExecutionWorkspaceMode === "agent_default" ? "adapter_managed" : "shared_workspace", strategyType: executionWorkspace.strategy === "git_worktree" ? "git_worktree" : "project_primary", @@ -2145,10 +2259,7 @@ export function heartbeatService(db: Db) { providerRef: executionWorkspace.worktreePath, lastUsedAt: new Date(), openedAt: new Date(), - metadata: { - source: executionWorkspace.source, - createdByRuntime: executionWorkspace.created, - }, + metadata: nextExecutionWorkspaceMetadata, }) : null; } catch (error) { @@ -2175,7 +2286,8 @@ export function heartbeatService(db: Db) { cwd: resolvedWorkspace.cwd, cleanupCommand: null, }, - teardownCommand: projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, + cleanupCommand: configSnapshot?.cleanupCommand ?? null, + teardownCommand: configSnapshot?.teardownCommand ?? projectExecutionWorkspacePolicy?.workspaceStrategy?.teardownCommand ?? null, recorder: workspaceOperationRecorder, }); } catch (cleanupError) { @@ -2208,8 +2320,8 @@ export function heartbeatService(db: Db) { const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); const shouldSwitchIssueToExistingWorkspace = issueRef?.executionWorkspacePreference === "reuse_existing" || - executionWorkspaceMode === "isolated_workspace" || - executionWorkspaceMode === "operator_branch"; + requestedExecutionWorkspaceMode === "isolated_workspace" || + requestedExecutionWorkspaceMode === "operator_branch"; const nextIssuePatch: Record = {}; if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; @@ -2262,7 +2374,7 @@ export function heartbeatService(db: Db) { context.paperclipWorkspace = { cwd: executionWorkspace.cwd, source: executionWorkspace.source, - mode: executionWorkspaceMode, + mode: effectiveExecutionWorkspaceMode, strategy: executionWorkspace.strategy, projectId: executionWorkspace.projectId, workspaceId: executionWorkspace.workspaceId, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index fccd6c7f..241355b6 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -28,5 +28,5 @@ export { workProductService } from "./work-products.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; export { publishLiveEvent, subscribeCompanyLiveEvents } from "./live-events.js"; -export { reconcilePersistedRuntimeServicesOnStartup } from "./workspace-runtime.js"; +export { reconcilePersistedRuntimeServicesOnStartup, restartDesiredRuntimeServicesOnStartup } from "./workspace-runtime.js"; export { createStorageServiceFromConfig, getStorageService } from "../storage/index.js"; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index a728cfe0..b6bdb066 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -11,6 +11,7 @@ import { heartbeatRuns, executionWorkspaces, issueAttachments, + issueInboxArchives, issueLabels, issueComments, issueDocuments, @@ -25,6 +26,7 @@ import { conflict, notFound, unprocessable } from "../errors.js"; import { defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseProjectExecutionWorkspacePolicy, } from "./execution-workspace-policy.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -66,8 +68,10 @@ export interface IssueFilters { participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; + inboxArchivedByUserId?: string; unreadForUserId?: string; projectId?: string; + executionWorkspaceId?: string; parentId?: string; labelId?: string; originKind?: string; @@ -102,6 +106,11 @@ type IssueUserContextInput = { updatedAt: Date | string; }; type ProjectGoalReader = Pick; +type DbReader = Pick; +type IssueCreateInput = Omit & { + labelIds?: string[]; + inheritExecutionWorkspaceFromIssueId?: string | null; +}; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -128,6 +137,28 @@ async function getProjectDefaultGoalId( return row?.goalId ?? null; } +async function getWorkspaceInheritanceIssue( + db: DbReader, + companyId: string, + issueId: string, +) { + const issue = await db + .select({ + id: issues.id, + projectId: issues.projectId, + projectWorkspaceId: issues.projectWorkspaceId, + executionWorkspaceId: issues.executionWorkspaceId, + executionWorkspaceSettings: issues.executionWorkspaceSettings, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!issue) { + throw notFound("Workspace inheritance issue not found"); + } + return issue; +} + function touchedByUserCondition(companyId: string, userId: string) { return sql` ( @@ -212,6 +243,36 @@ function myLastTouchAtExpr(companyId: string, userId: string) { `; } +function lastExternalCommentAtExpr(companyId: string, userId: string) { + return sql` + ( + SELECT MAX(${issueComments.createdAt}) + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ( + ${issueComments.authorUserId} IS NULL + OR ${issueComments.authorUserId} <> ${userId} + ) + ) + `; +} + +function issueLastActivityAtExpr(companyId: string, userId: string) { + const lastExternalCommentAt = lastExternalCommentAtExpr(companyId, userId); + const myLastTouchAt = myLastTouchAtExpr(companyId, userId); + return sql` + COALESCE( + ${lastExternalCommentAt}, + CASE + WHEN ${issues.updatedAt} > COALESCE(${myLastTouchAt}, to_timestamp(0)) + THEN ${issues.updatedAt} + ELSE to_timestamp(0) + END + ) + `; +} + function unreadForUserCondition(companyId: string, userId: string) { const touchedCondition = touchedByUserCondition(companyId, userId); const myLastTouchAt = myLastTouchAtExpr(companyId, userId); @@ -233,6 +294,20 @@ function unreadForUserCondition(companyId: string, userId: string) { `; } +function inboxVisibleForUserCondition(companyId: string, userId: string) { + const issueLastActivityAt = issueLastActivityAtExpr(companyId, userId); + return sql` + NOT EXISTS ( + SELECT 1 + FROM ${issueInboxArchives} + WHERE ${issueInboxArchives.issueId} = ${issues.id} + AND ${issueInboxArchives.companyId} = ${companyId} + AND ${issueInboxArchives.userId} = ${userId} + AND ${issueInboxArchives.archivedAt} >= ${issueLastActivityAt} + ) + `; +} + /** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */ const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly> = { amp: "&", @@ -440,8 +515,13 @@ export function issueService(db: Db) { } } - async function assertValidProjectWorkspace(companyId: string, projectId: string | null | undefined, projectWorkspaceId: string) { - const workspace = await db + async function assertValidProjectWorkspace( + companyId: string, + projectId: string | null | undefined, + projectWorkspaceId: string, + dbOrTx: DbReader = db, + ) { + const workspace = await dbOrTx .select({ id: projectWorkspaces.id, companyId: projectWorkspaces.companyId, @@ -457,8 +537,13 @@ export function issueService(db: Db) { } } - async function assertValidExecutionWorkspace(companyId: string, projectId: string | null | undefined, executionWorkspaceId: string) { - const workspace = await db + async function assertValidExecutionWorkspace( + companyId: string, + projectId: string | null | undefined, + executionWorkspaceId: string, + dbOrTx: DbReader = db, + ) { + const workspace = await dbOrTx .select({ id: executionWorkspaces.id, companyId: executionWorkspaces.companyId, @@ -556,8 +641,9 @@ export function issueService(db: Db) { list: async (companyId: string, filters?: IssueFilters) => { const conditions = [eq(issues.companyId, companyId)]; const touchedByUserId = filters?.touchedByUserId?.trim() || undefined; + const inboxArchivedByUserId = filters?.inboxArchivedByUserId?.trim() || undefined; const unreadForUserId = filters?.unreadForUserId?.trim() || undefined; - const contextUserId = unreadForUserId ?? touchedByUserId; + const contextUserId = unreadForUserId ?? touchedByUserId ?? inboxArchivedByUserId; const rawSearch = filters?.q?.trim() ?? ""; const hasSearch = rawSearch.length > 0; const escapedSearch = hasSearch ? escapeLikePattern(rawSearch) : ""; @@ -593,10 +679,16 @@ export function issueService(db: Db) { if (touchedByUserId) { conditions.push(touchedByUserCondition(companyId, touchedByUserId)); } + if (inboxArchivedByUserId) { + conditions.push(inboxVisibleForUserCondition(companyId, inboxArchivedByUserId)); + } if (unreadForUserId) { conditions.push(unreadForUserCondition(companyId, unreadForUserId)); } if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId)); + if (filters?.executionWorkspaceId) { + conditions.push(eq(issues.executionWorkspaceId, filters.executionWorkspaceId)); + } if (filters?.parentId) conditions.push(eq(issues.parentId, filters.parentId)); if (filters?.originKind) conditions.push(eq(issues.originKind, filters.originKind)); if (filters?.originId) conditions.push(eq(issues.originId, filters.originId)); @@ -741,6 +833,56 @@ export function issueService(db: Db) { return row; }, + markUnread: async (companyId: string, issueId: string, userId: string) => { + const deleted = await db + .delete(issueReadStates) + .where( + and( + eq(issueReadStates.companyId, companyId), + eq(issueReadStates.issueId, issueId), + eq(issueReadStates.userId, userId), + ), + ) + .returning(); + return deleted.length > 0; + }, + + archiveInbox: async (companyId: string, issueId: string, userId: string, archivedAt: Date = new Date()) => { + const now = new Date(); + const [row] = await db + .insert(issueInboxArchives) + .values({ + companyId, + issueId, + userId, + archivedAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [issueInboxArchives.companyId, issueInboxArchives.issueId, issueInboxArchives.userId], + set: { + archivedAt, + updatedAt: now, + }, + }) + .returning(); + return row; + }, + + unarchiveInbox: async (companyId: string, issueId: string, userId: string) => { + const [row] = await db + .delete(issueInboxArchives) + .where( + and( + eq(issueInboxArchives.companyId, companyId), + eq(issueInboxArchives.issueId, issueId), + eq(issueInboxArchives.userId, userId), + ), + ) + .returning(); + return row ?? null; + }, + getById: async (id: string) => { const row = await db .select() @@ -765,9 +907,9 @@ export function issueService(db: Db) { create: async ( companyId: string, - data: Omit & { labelIds?: string[] }, + data: IssueCreateInput, ) => { - const { labelIds: inputLabelIds, ...issueData } = data; + const { labelIds: inputLabelIds, inheritExecutionWorkspaceFromIssueId, ...issueData } = data; const isolatedWorkspacesEnabled = (await instanceSettings.getExperimental()).enableIsolatedWorkspaces; if (!isolatedWorkspacesEnabled) { delete issueData.executionWorkspaceId; @@ -783,21 +925,55 @@ export function issueService(db: Db) { if (data.assigneeUserId) { await assertAssignableUser(companyId, data.assigneeUserId); } - if (data.projectWorkspaceId) { - await assertValidProjectWorkspace(companyId, data.projectId, data.projectWorkspaceId); - } - if (data.executionWorkspaceId) { - await assertValidExecutionWorkspace(companyId, data.projectId, data.executionWorkspaceId); - } if (data.status === "in_progress" && !data.assigneeAgentId && !data.assigneeUserId) { throw unprocessable("in_progress issues require an assignee"); } return db.transaction(async (tx) => { const defaultCompanyGoal = await getDefaultCompanyGoal(tx, companyId); const projectGoalId = await getProjectDefaultGoalId(tx, companyId, issueData.projectId); + let projectWorkspaceId = issueData.projectWorkspaceId ?? null; + let executionWorkspaceId = issueData.executionWorkspaceId ?? null; + let executionWorkspacePreference = issueData.executionWorkspacePreference ?? null; let executionWorkspaceSettings = (issueData.executionWorkspaceSettings as Record | null | undefined) ?? null; - if (executionWorkspaceSettings == null && issueData.projectId) { + const workspaceInheritanceIssueId = inheritExecutionWorkspaceFromIssueId ?? issueData.parentId ?? null; + const hasExplicitExecutionWorkspaceOverride = + issueData.executionWorkspaceId !== undefined || + issueData.executionWorkspacePreference !== undefined || + issueData.executionWorkspaceSettings !== undefined; + if (workspaceInheritanceIssueId) { + const workspaceSource = await getWorkspaceInheritanceIssue(tx, companyId, workspaceInheritanceIssueId); + if (projectWorkspaceId == null && workspaceSource.projectWorkspaceId) { + projectWorkspaceId = workspaceSource.projectWorkspaceId; + } + if ( + isolatedWorkspacesEnabled && + !hasExplicitExecutionWorkspaceOverride && + workspaceSource.executionWorkspaceId + ) { + const sourceWorkspace = await tx + .select({ + id: executionWorkspaces.id, + mode: executionWorkspaces.mode, + }) + .from(executionWorkspaces) + .where(eq(executionWorkspaces.id, workspaceSource.executionWorkspaceId)) + .then((rows) => rows[0] ?? null); + if (sourceWorkspace) { + executionWorkspaceId = sourceWorkspace.id; + executionWorkspacePreference = "reuse_existing"; + executionWorkspaceSettings = { + ...((workspaceSource.executionWorkspaceSettings as Record | null | undefined) ?? {}), + mode: issueExecutionWorkspaceModeForPersistedWorkspace(sourceWorkspace.mode), + }; + } + } + } + if ( + executionWorkspaceSettings == null && + executionWorkspaceId == null && + issueData.projectId + ) { const project = await tx .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) .from(projects) @@ -811,7 +987,6 @@ export function issueService(db: Db) { ), ) as Record | null; } - let projectWorkspaceId = issueData.projectWorkspaceId ?? null; if (!projectWorkspaceId && issueData.projectId) { const project = await tx .select({ @@ -831,6 +1006,12 @@ export function issueService(db: Db) { .then((rows) => rows[0]?.id ?? null); } } + if (projectWorkspaceId) { + await assertValidProjectWorkspace(companyId, issueData.projectId, projectWorkspaceId, tx); + } + if (executionWorkspaceId) { + await assertValidExecutionWorkspace(companyId, issueData.projectId, executionWorkspaceId, tx); + } const [company] = await tx .update(companies) .set({ issueCounter: sql`${companies.issueCounter} + 1` }) @@ -850,6 +1031,8 @@ export function issueService(db: Db) { defaultGoalId: defaultCompanyGoal?.id ?? null, }), ...(projectWorkspaceId ? { projectWorkspaceId } : {}), + ...(executionWorkspaceId ? { executionWorkspaceId } : {}), + ...(executionWorkspacePreference ? { executionWorkspacePreference } : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), companyId, issueNumber, diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts new file mode 100644 index 00000000..68dbbdc8 --- /dev/null +++ b/server/src/services/local-service-supervisor.ts @@ -0,0 +1,302 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; + +const execFileAsync = promisify(execFile); + +export interface LocalServiceRegistryRecord { + version: 1; + serviceKey: string; + profileKind: string; + serviceName: string; + command: string; + cwd: string; + envFingerprint: string; + port: number | null; + url: string | null; + pid: number; + processGroupId: number | null; + provider: "local_process"; + runtimeServiceId: string | null; + reuseKey: string | null; + startedAt: string; + lastSeenAt: string; + metadata: Record | null; +} + +export interface LocalServiceIdentityInput { + profileKind: string; + serviceName: string; + cwd: string; + command: string; + envFingerprint: string; + port: number | null; + scope: Record | null; +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const rec = value as Record; + return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(rec[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function sanitizeServiceKeySegment(value: string, fallback: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return normalized || fallback; +} + +function getRuntimeServicesDir() { + return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services"); +} + +function getRuntimeServiceRegistryPath(serviceKey: string) { + return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`); +} + +function normalizeRegistryRecord(raw: unknown): LocalServiceRegistryRecord | null { + if (!raw || typeof raw !== "object") return null; + const rec = raw as Record; + if ( + rec.version !== 1 || + typeof rec.serviceKey !== "string" || + typeof rec.profileKind !== "string" || + typeof rec.serviceName !== "string" || + typeof rec.command !== "string" || + typeof rec.cwd !== "string" || + typeof rec.envFingerprint !== "string" || + typeof rec.pid !== "number" + ) { + return null; + } + + return { + version: 1, + serviceKey: rec.serviceKey, + profileKind: rec.profileKind, + serviceName: rec.serviceName, + command: rec.command, + cwd: rec.cwd, + envFingerprint: rec.envFingerprint, + port: typeof rec.port === "number" ? rec.port : null, + url: typeof rec.url === "string" ? rec.url : null, + pid: rec.pid, + processGroupId: typeof rec.processGroupId === "number" ? rec.processGroupId : null, + provider: "local_process", + runtimeServiceId: typeof rec.runtimeServiceId === "string" ? rec.runtimeServiceId : null, + reuseKey: typeof rec.reuseKey === "string" ? rec.reuseKey : null, + startedAt: typeof rec.startedAt === "string" ? rec.startedAt : new Date().toISOString(), + lastSeenAt: typeof rec.lastSeenAt === "string" ? rec.lastSeenAt : new Date().toISOString(), + metadata: + rec.metadata && typeof rec.metadata === "object" && !Array.isArray(rec.metadata) + ? (rec.metadata as Record) + : null, + }; +} + +async function safeReadRegistryRecord(filePath: string) { + try { + const raw = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; + return normalizeRegistryRecord(raw); + } catch { + return null; + } +} + +export function createLocalServiceKey(input: LocalServiceIdentityInput) { + const digest = createHash("sha256") + .update( + stableStringify({ + profileKind: input.profileKind, + serviceName: input.serviceName, + cwd: path.resolve(input.cwd), + command: input.command, + envFingerprint: input.envFingerprint, + port: input.port, + scope: input.scope ?? null, + }), + ) + .digest("hex") + .slice(0, 24); + + return `${sanitizeServiceKeySegment(input.profileKind, "service")}-${sanitizeServiceKeySegment(input.serviceName, "service")}-${digest}`; +} + +export async function writeLocalServiceRegistryRecord(record: LocalServiceRegistryRecord) { + await fs.mkdir(getRuntimeServicesDir(), { recursive: true }); + await fs.writeFile( + getRuntimeServiceRegistryPath(record.serviceKey), + `${JSON.stringify(record, null, 2)}\n`, + "utf8", + ); +} + +export async function removeLocalServiceRegistryRecord(serviceKey: string) { + await fs.rm(getRuntimeServiceRegistryPath(serviceKey), { force: true }); +} + +export async function readLocalServiceRegistryRecord(serviceKey: string) { + return await safeReadRegistryRecord(getRuntimeServiceRegistryPath(serviceKey)); +} + +export async function listLocalServiceRegistryRecords(filter?: { + profileKind?: string; + metadata?: Record; +}) { + try { + const entries = await fs.readdir(getRuntimeServicesDir(), { withFileTypes: true }); + const records = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => safeReadRegistryRecord(path.resolve(getRuntimeServicesDir(), entry.name))), + ); + + return records + .filter((record): record is LocalServiceRegistryRecord => record !== null) + .filter((record) => { + if (filter?.profileKind && record.profileKind !== filter.profileKind) return false; + if (!filter?.metadata) return true; + return Object.entries(filter.metadata).every(([key, value]) => record.metadata?.[key] === value); + }) + .sort((left, right) => left.serviceKey.localeCompare(right.serviceKey)); + } catch { + return []; + } +} + +export async function findLocalServiceRegistryRecordByRuntimeServiceId(input: { + runtimeServiceId: string; + profileKind?: string; +}) { + const records = await listLocalServiceRegistryRecords( + input.profileKind ? { profileKind: input.profileKind } : undefined, + ); + return records.find((record) => record.runtimeServiceId === input.runtimeServiceId) ?? null; +} + +export function isPidAlive(pid: number) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { + if (process.platform === "win32") return true; + try { + const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]); + const commandLine = stdout.trim(); + if (!commandLine) return false; + return commandLine.includes(record.command) || commandLine.includes(record.serviceName); + } catch { + return true; + } +} + +export async function findAdoptableLocalService(input: { + serviceKey: string; + command?: string | null; + cwd?: string | null; + envFingerprint?: string | null; + port?: number | null; +}) { + const record = await readLocalServiceRegistryRecord(input.serviceKey); + if (!record) return null; + + if (!isPidAlive(record.pid)) { + await removeLocalServiceRegistryRecord(input.serviceKey); + return null; + } + if (!(await isLikelyMatchingCommand(record))) { + await removeLocalServiceRegistryRecord(input.serviceKey); + return null; + } + if (input.command && record.command !== input.command) return null; + if (input.cwd && path.resolve(record.cwd) !== path.resolve(input.cwd)) return null; + if (input.envFingerprint && record.envFingerprint !== input.envFingerprint) return null; + if (input.port !== undefined && input.port !== null && record.port !== input.port) return null; + return record; +} + +export async function touchLocalServiceRegistryRecord( + serviceKey: string, + patch?: Partial>, +) { + const existing = await readLocalServiceRegistryRecord(serviceKey); + if (!existing) return null; + const next: LocalServiceRegistryRecord = { + ...existing, + ...patch, + version: 1, + serviceKey, + lastSeenAt: patch?.lastSeenAt ?? new Date().toISOString(), + }; + await writeLocalServiceRegistryRecord(next); + return next; +} + +export async function terminateLocalService( + record: Pick, + opts?: { signal?: NodeJS.Signals; forceAfterMs?: number }, +) { + const signal = opts?.signal ?? "SIGTERM"; + const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0; + try { + if (targetProcessGroup) { + process.kill(-record.processGroupId!, signal); + } else { + process.kill(record.pid, signal); + } + } catch { + return; + } + + const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000); + while (Date.now() < deadline) { + if (!isPidAlive(record.pid)) { + return; + } + await delay(100); + } + + if (!isPidAlive(record.pid)) return; + try { + if (targetProcessGroup) { + process.kill(-record.processGroupId!, "SIGKILL"); + } else { + process.kill(record.pid, "SIGKILL"); + } + } catch { + // Ignore cleanup races. + } +} + +export async function readLocalServicePortOwner(port: number) { + if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null; + try { + const { stdout } = await execFileAsync("lsof", ["-nPiTCP", `:${port}`, "-sTCP:LISTEN", "-t"]); + const firstPid = stdout + .split("\n") + .map((line) => Number.parseInt(line.trim(), 10)) + .find((value) => Number.isInteger(value) && value > 0); + return firstPid ?? null; + } catch { + return null; + } +} diff --git a/server/src/services/project-workspace-runtime-config.ts b/server/src/services/project-workspace-runtime-config.ts new file mode 100644 index 00000000..8252fecd --- /dev/null +++ b/server/src/services/project-workspace-runtime-config.ts @@ -0,0 +1,59 @@ +import type { ProjectWorkspaceRuntimeConfig } from "@paperclipai/shared"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cloneRecord(value: unknown): Record | null { + return isRecord(value) ? { ...value } : null; +} + +function readDesiredState(value: unknown): ProjectWorkspaceRuntimeConfig["desiredState"] { + return value === "running" || value === "stopped" ? value : null; +} + +export function readProjectWorkspaceRuntimeConfig( + metadata: Record | null | undefined, +): ProjectWorkspaceRuntimeConfig | null { + const raw = isRecord(metadata?.runtimeConfig) ? metadata.runtimeConfig : null; + if (!raw) return null; + + const config: ProjectWorkspaceRuntimeConfig = { + workspaceRuntime: cloneRecord(raw.workspaceRuntime), + desiredState: readDesiredState(raw.desiredState), + }; + + const hasConfig = config.workspaceRuntime !== null || config.desiredState !== null; + return hasConfig ? config : null; +} + +export function mergeProjectWorkspaceRuntimeConfig( + metadata: Record | null | undefined, + patch: Partial | null, +): Record | null { + const nextMetadata = isRecord(metadata) ? { ...metadata } : {}; + const current = readProjectWorkspaceRuntimeConfig(metadata) ?? { + workspaceRuntime: null, + desiredState: null, + }; + + if (patch === null) { + delete nextMetadata.runtimeConfig; + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; + } + + const nextConfig: ProjectWorkspaceRuntimeConfig = { + workspaceRuntime: + patch.workspaceRuntime !== undefined ? cloneRecord(patch.workspaceRuntime) : current.workspaceRuntime, + desiredState: + patch.desiredState !== undefined ? readDesiredState(patch.desiredState) : current.desiredState, + }; + + if (nextConfig.workspaceRuntime === null && nextConfig.desiredState === null) { + delete nextMetadata.runtimeConfig; + } else { + nextMetadata.runtimeConfig = nextConfig; + } + + return Object.keys(nextMetadata).length > 0 ? nextMetadata : null; +} diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 4f7d1eb2..7c7c4bb0 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -9,11 +9,13 @@ import { type ProjectCodebase, type ProjectExecutionWorkspacePolicy, type ProjectGoalRef, + type ProjectWorkspaceRuntimeConfig, type ProjectWorkspace, type WorkspaceRuntimeService, } from "@paperclipai/shared"; import { listWorkspaceRuntimeServicesForProjectWorkspaces } from "./workspace-runtime.js"; import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; type ProjectRow = typeof projects.$inferSelect; @@ -34,6 +36,7 @@ type CreateWorkspaceInput = { remoteWorkspaceRef?: string | null; sharedWorkspaceKey?: string | null; metadata?: Record | null; + runtimeConfig?: Partial | null; isPrimary?: boolean; }; type UpdateWorkspaceInput = Partial; @@ -149,6 +152,7 @@ function toWorkspace( remoteWorkspaceRef: row.remoteWorkspaceRef ?? null, sharedWorkspaceKey: row.sharedWorkspaceKey ?? null, metadata: (row.metadata as Record | null) ?? null, + runtimeConfig: readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null), isPrimary: row.isPrimary, runtimeServices, createdAt: row.createdAt, @@ -611,7 +615,13 @@ export function projectService(db: Db) { remoteProvider: readNonEmptyString(data.remoteProvider), remoteWorkspaceRef, sharedWorkspaceKey: readNonEmptyString(data.sharedWorkspaceKey), - metadata: (data.metadata as Record | null | undefined) ?? null, + metadata: + data.runtimeConfig !== undefined + ? mergeProjectWorkspaceRuntimeConfig( + (data.metadata as Record | null | undefined) ?? null, + data.runtimeConfig ?? null, + ) + : (data.metadata as Record | null | undefined) ?? null, isPrimary: shouldBePrimary, }) .returning() @@ -681,7 +691,17 @@ export function projectService(db: Db) { if (data.remoteProvider !== undefined) patch.remoteProvider = readNonEmptyString(data.remoteProvider); if (data.remoteWorkspaceRef !== undefined) patch.remoteWorkspaceRef = nextRemoteWorkspaceRef; if (data.sharedWorkspaceKey !== undefined) patch.sharedWorkspaceKey = readNonEmptyString(data.sharedWorkspaceKey); - if (data.metadata !== undefined) patch.metadata = data.metadata; + if (data.metadata !== undefined || data.runtimeConfig !== undefined) { + patch.metadata = + data.runtimeConfig !== undefined + ? mergeProjectWorkspaceRuntimeConfig( + data.metadata !== undefined + ? (data.metadata as Record | null | undefined) + : ((existing.metadata as Record | null | undefined) ?? null), + data.runtimeConfig ?? null, + ) + : data.metadata; + } const updated = await db.transaction(async (tx) => { if (data.isPrimary === true) { diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 7cb780ce..dd51d9c6 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -6,11 +6,23 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import type { AdapterRuntimeServiceReport } from "@paperclipai/adapter-utils"; import type { Db } from "@paperclipai/db"; -import { workspaceRuntimeServices } from "@paperclipai/db"; +import { executionWorkspaces, projectWorkspaces, workspaceRuntimeServices } from "@paperclipai/db"; import { and, desc, eq, inArray } from "drizzle-orm"; import { asNumber, asString, parseObject, renderTemplate } from "../adapters/utils.js"; import { resolveHomeAwarePath } from "../home-paths.js"; +import { + createLocalServiceKey, + findLocalServiceRegistryRecordByRuntimeServiceId, + findAdoptableLocalService, + readLocalServicePortOwner, + removeLocalServiceRegistryRecord, + terminateLocalService, + touchLocalServiceRegistryRecord, + writeLocalServiceRegistryRecord, +} from "./local-service-supervisor.js"; import type { WorkspaceOperationRecorder } from "./workspace-operations.js"; +import { readExecutionWorkspaceConfig } from "./execution-workspaces.js"; +import { readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js"; export interface ExecutionWorkspaceInput { baseCwd: string; @@ -28,7 +40,7 @@ export interface ExecutionWorkspaceIssueRef { } export interface ExecutionWorkspaceAgentRef { - id: string; + id: string | null; name: string; companyId: string; } @@ -77,12 +89,24 @@ interface RuntimeServiceRecord extends RuntimeServiceRef { leaseRunIds: Set; idleTimer: ReturnType | null; envFingerprint: string; + serviceKey: string; + profileKind: string; + processGroupId: number | null; } const runtimeServicesById = new Map(); const runtimeServicesByReuseKey = new Map(); const runtimeServiceLeasesByRun = new Map(); +export async function resetRuntimeServicesForTests() { + for (const record of runtimeServicesById.values()) { + clearIdleTimer(record); + } + runtimeServicesById.clear(); + runtimeServicesByReuseKey.clear(); + runtimeServiceLeasesByRun.clear(); +} + function stableStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; @@ -102,6 +126,8 @@ export function sanitizeRuntimeServiceBaseEnv(baseEnv: NodeJS.ProcessEnv): NodeJ } } delete env.DATABASE_URL; + delete env.npm_config_tailscale_auth; + delete env.npm_config_authenticated_private; return env; } @@ -168,9 +194,9 @@ function toRuntimeServiceRef(record: RuntimeServiceRecord, overrides?: Partial 0 ? normalized : fallback; } @@ -189,7 +215,7 @@ function renderWorkspaceTemplate(template: string, input: { title: input.issue?.title ?? "", }, agent: { - id: input.agent.id, + id: input.agent.id ?? "", name: input.agent.name, }, project: { @@ -312,7 +338,7 @@ function buildWorkspaceCommandEnv(input: { env.PAPERCLIP_WORKSPACE_CREATED = input.created ? "true" : "false"; env.PAPERCLIP_PROJECT_ID = input.base.projectId ?? ""; env.PAPERCLIP_PROJECT_WORKSPACE_ID = input.base.workspaceId ?? ""; - env.PAPERCLIP_AGENT_ID = input.agent.id; + env.PAPERCLIP_AGENT_ID = input.agent.id ?? ""; env.PAPERCLIP_AGENT_NAME = input.agent.name; env.PAPERCLIP_COMPANY_ID = input.agent.companyId; env.PAPERCLIP_ISSUE_ID = input.issue?.id ?? ""; @@ -702,6 +728,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { cwd: string | null; cleanupCommand: string | null; } | null; + cleanupCommand?: string | null; teardownCommand?: string | null; recorder?: WorkspaceOperationRecorder | null; }) { @@ -713,6 +740,7 @@ export async function cleanupExecutionWorkspaceArtifacts(input: { }); const createdByRuntime = input.workspace.metadata?.createdByRuntime === true; const cleanupCommands = [ + input.cleanupCommand ?? null, input.projectWorkspace?.cleanupCommand ?? null, input.teardownCommand ?? null, ] @@ -879,13 +907,95 @@ function buildTemplateData(input: { title: input.issue?.title ?? "", }, agent: { - id: input.agent.id, + id: input.agent.id ?? "", name: input.agent.name, }, port: input.port ?? "", }; } +function renderRuntimeServiceEnv(input: { + envConfig: Record; + templateData: ReturnType; +}) { + const rendered: Record = {}; + for (const [key, value] of Object.entries(input.envConfig)) { + if (typeof value !== "string") continue; + rendered[key] = renderTemplate(value, input.templateData); + } + return rendered; +} + +function resolveRuntimeServiceReuseIdentity(input: { + service: Record; + workspace: RealizedExecutionWorkspace; + agent: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + adapterEnv: Record; + scopeType: RuntimeServiceRef["scopeType"]; + scopeId: string | null; +}): { + serviceName: string; + lifecycle: RuntimeServiceRef["lifecycle"]; + command: string; + serviceCwd: string; + envConfig: Record; + envFingerprint: string; + explicitPort: number; + identityPort: number | null; + reuseKey: string | null; +} { + const serviceName = asString(input.service.name, "service"); + const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; + const command = asString(input.service.command, ""); + const serviceCwdTemplate = asString(input.service.cwd, "."); + const portConfig = parseObject(input.service.port); + const envConfig = parseObject(input.service.env); + const explicitPort = asNumber(portConfig.value, asNumber(input.service.port, 0)); + const identityPort = explicitPort > 0 ? explicitPort : null; + const templateData = buildTemplateData({ + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + port: identityPort, + }); + const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); + const renderedEnv = renderRuntimeServiceEnv({ + envConfig, + templateData, + }); + const envFingerprint = createHash("sha256").update(stableStringify(renderedEnv)).digest("hex"); + const reuseKey = + lifecycle === "shared" + ? createHash("sha256") + .update( + stableStringify({ + scopeType: input.scopeType, + scopeId: input.scopeId, + serviceName, + command, + cwd: serviceCwd, + port: identityPort, + env: renderedEnv, + }), + ) + .digest("hex") + : null; + + return { + serviceName, + lifecycle, + command, + serviceCwd, + envConfig, + envFingerprint, + explicitPort, + identityPort, + reuseKey, + }; +} + function resolveServiceScopeId(input: { service: Record; workspace: RealizedExecutionWorkspace; @@ -1067,7 +1177,7 @@ export function normalizeAdapterManagedRuntimeServices(input: { url: report.url ?? null, provider: "adapter_managed", providerRef: report.providerRef ?? null, - ownerAgentId: report.ownerAgentId ?? input.agent.id, + ownerAgentId: report.ownerAgentId ?? input.agent.id ?? null, startedByRunId: input.runId, lastUsedAt: nowIso, startedAt: nowIso, @@ -1082,6 +1192,8 @@ export function normalizeAdapterManagedRuntimeServices(input: { async function startLocalRuntimeService(input: { db?: Db; runId: string; + leaseRunId?: string | null; + startedByRunId?: string | null; agent: ExecutionWorkspaceAgentRef; issue: ExecutionWorkspaceIssueRef | null; workspace: RealizedExecutionWorkspace; @@ -1093,14 +1205,33 @@ async function startLocalRuntimeService(input: { scopeType: "project_workspace" | "execution_workspace" | "run" | "agent"; scopeId: string | null; }): Promise { - const serviceName = asString(input.service.name, "service"); - const lifecycle = asString(input.service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; - const command = asString(input.service.command, ""); + const leaseRunId = input.leaseRunId === undefined ? input.runId : input.leaseRunId; + const startedByRunId = input.startedByRunId === undefined ? input.runId : input.startedByRunId; + const identity = resolveRuntimeServiceReuseIdentity({ + service: input.service, + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType: input.scopeType, + scopeId: input.scopeId, + }); + const serviceName = identity.serviceName; + const lifecycle = identity.lifecycle; + const command = identity.command; if (!command) throw new Error(`Runtime service "${serviceName}" is missing command`); - const serviceCwdTemplate = asString(input.service.cwd, "."); const portConfig = parseObject(input.service.port); - const port = asString(portConfig.type, "") === "auto" ? await allocatePort() : null; - const envConfig = parseObject(input.service.env); + const envConfig = identity.envConfig; + const envFingerprint = identity.envFingerprint; + const serviceIdentityFingerprint = input.reuseKey ?? envFingerprint; + const explicitPort = identity.explicitPort; + const identityPort = identity.identityPort; + const port = + asString(portConfig.type, "") === "auto" + ? await allocatePort() + : explicitPort > 0 + ? explicitPort + : null; const templateData = buildTemplateData({ workspace: input.workspace, agent: input.agent, @@ -1108,20 +1239,95 @@ async function startLocalRuntimeService(input: { adapterEnv: input.adapterEnv, port, }); - const serviceCwd = resolveConfiguredPath(renderTemplate(serviceCwdTemplate, templateData), input.workspace.cwd); + const serviceCwd = + port === identityPort + ? identity.serviceCwd + : resolveConfiguredPath(renderTemplate(asString(input.service.cwd, "."), templateData), input.workspace.cwd); const env: Record = { ...sanitizeRuntimeServiceBaseEnv(process.env), ...input.adapterEnv, } as Record; - for (const [key, value] of Object.entries(envConfig)) { - if (typeof value === "string") { - env[key] = renderTemplate(value, templateData); - } + for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) { + env[key] = value; } if (port) { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port); } + const expose = parseObject(input.service.expose); + const readiness = parseObject(input.service.readiness); + const urlTemplate = + asString(expose.urlTemplate, "") || + asString(readiness.urlTemplate, ""); + const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null; + const stopPolicy = parseObject(input.service.stopPolicy); + const serviceKey = createLocalServiceKey({ + profileKind: "workspace-runtime", + serviceName, + cwd: serviceCwd, + command, + envFingerprint: serviceIdentityFingerprint, + port: identityPort, + scope: { + scopeType: input.scopeType, + scopeId: input.scopeId, + executionWorkspaceId: input.executionWorkspaceId ?? null, + reuseKey: input.reuseKey, + }, + }); + const adoptedRecord = await findAdoptableLocalService({ + serviceKey, + command, + cwd: serviceCwd, + envFingerprint: serviceIdentityFingerprint, + port: identityPort, + }); + if (adoptedRecord) { + return { + id: adoptedRecord.runtimeServiceId ?? randomUUID(), + companyId: input.agent.companyId, + projectId: input.workspace.projectId, + projectWorkspaceId: input.workspace.workspaceId, + executionWorkspaceId: input.executionWorkspaceId ?? null, + issueId: input.issue?.id ?? null, + serviceName, + status: "running", + lifecycle, + scopeType: input.scopeType, + scopeId: input.scopeId, + reuseKey: input.reuseKey, + command, + cwd: serviceCwd, + port: adoptedRecord.port ?? port, + url: adoptedRecord.url ?? url, + provider: "local_process", + providerRef: String(adoptedRecord.pid), + ownerAgentId: input.agent.id ?? null, + startedByRunId, + lastUsedAt: new Date().toISOString(), + startedAt: adoptedRecord.startedAt, + stoppedAt: null, + stopPolicy, + healthStatus: "healthy", + reused: true, + db: input.db, + child: null, + leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(), + idleTimer: null, + envFingerprint, + serviceKey, + profileKind: "workspace-runtime", + processGroupId: adoptedRecord.processGroupId ?? null, + }; + } + if (identityPort) { + const ownerPid = await readLocalServicePortOwner(identityPort); + if (ownerPid) { + throw new Error( + `Runtime service "${serviceName}" could not start because port ${identityPort} is already in use by pid ${ownerPid}`, + ); + } + } const shell = process.env.SHELL?.trim() || "/bin/sh"; const child = spawn(shell, ["-lc", command], { cwd: serviceCwd, @@ -1142,13 +1348,6 @@ async function startLocalRuntimeService(input: { if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`); }); - const expose = parseObject(input.service.expose); - const readiness = parseObject(input.service.readiness); - const urlTemplate = - asString(expose.urlTemplate, "") || - asString(readiness.urlTemplate, ""); - const url = urlTemplate ? renderTemplate(urlTemplate, templateData) : null; - try { await waitForReadiness({ service: input.service, url }); } catch (err) { @@ -1158,8 +1357,7 @@ async function startLocalRuntimeService(input: { ); } - const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); - return { + const record: RuntimeServiceRecord = { id: randomUUID(), companyId: input.agent.companyId, projectId: input.workspace.projectId, @@ -1178,20 +1376,54 @@ async function startLocalRuntimeService(input: { url, provider: "local_process", providerRef: child.pid ? String(child.pid) : null, - ownerAgentId: input.agent.id, - startedByRunId: input.runId, + ownerAgentId: input.agent.id ?? null, + startedByRunId, lastUsedAt: new Date().toISOString(), startedAt: new Date().toISOString(), stoppedAt: null, - stopPolicy: parseObject(input.service.stopPolicy), + stopPolicy, healthStatus: "healthy", reused: false, db: input.db, child, - leaseRunIds: new Set([input.runId]), + leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(), idleTimer: null, envFingerprint, + serviceKey, + profileKind: "workspace-runtime", + processGroupId: child.pid ?? null, }; + + if (child.pid) { + await writeLocalServiceRegistryRecord({ + version: 1, + serviceKey, + profileKind: "workspace-runtime", + serviceName, + command, + cwd: serviceCwd, + envFingerprint: serviceIdentityFingerprint, + port, + url, + pid: child.pid, + processGroupId: child.pid, + provider: "local_process", + runtimeServiceId: record.id, + reuseKey: input.reuseKey, + startedAt: record.startedAt, + lastSeenAt: record.lastUsedAt, + metadata: { + projectId: record.projectId, + projectWorkspaceId: record.projectWorkspaceId, + executionWorkspaceId: record.executionWorkspaceId, + issueId: record.issueId, + scopeType: record.scopeType, + scopeId: record.scopeId, + }, + }); + } + + return record; } function scheduleIdleStop(record: RuntimeServiceRecord) { @@ -1209,15 +1441,28 @@ async function stopRuntimeService(serviceId: string) { if (!record) return; clearIdleTimer(record); record.status = "stopped"; + record.healthStatus = "unknown"; record.lastUsedAt = new Date().toISOString(); record.stoppedAt = new Date().toISOString(); - if (record.child && record.child.pid) { - terminateChildProcess(record.child); - } runtimeServicesById.delete(serviceId); - if (record.reuseKey) { + if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) { runtimeServicesByReuseKey.delete(record.reuseKey); } + if (record.child && record.child.pid) { + await terminateLocalService({ + pid: record.child.pid, + processGroupId: record.processGroupId ?? record.child.pid, + }); + } else if (record.providerRef) { + const pid = Number.parseInt(record.providerRef, 10); + if (Number.isInteger(pid) && pid > 0) { + await terminateLocalService({ + pid, + processGroupId: record.processGroupId, + }); + } + } + await removeLocalServiceRegistryRecord(record.serviceKey); await persistRuntimeServiceRecord(record.db, record); } @@ -1262,10 +1507,18 @@ function registerRuntimeService(db: Db | undefined, record: RuntimeServiceRecord if (current.reuseKey && runtimeServicesByReuseKey.get(current.reuseKey) === current.id) { runtimeServicesByReuseKey.delete(current.reuseKey); } + void removeLocalServiceRegistryRecord(current.serviceKey); void persistRuntimeServiceRecord(db, current); }); } +function readRuntimeServiceEntries(config: Record) { + const runtime = parseObject(config.workspaceRuntime); + return Array.isArray(runtime.services) + ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) + : []; +} + export async function ensureRuntimeServicesForRun(input: { db?: Db; runId: string; @@ -1277,17 +1530,13 @@ export async function ensureRuntimeServicesForRun(input: { adapterEnv: Record; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; }): Promise { - const runtime = parseObject(input.config.workspaceRuntime); - const rawServices = Array.isArray(runtime.services) - ? runtime.services.filter((entry): entry is Record => typeof entry === "object" && entry !== null) - : []; + const rawServices = readRuntimeServiceEntries(input.config); const acquiredServiceIds: string[] = []; const refs: RuntimeServiceRef[] = []; runtimeServiceLeasesByRun.set(input.runId, acquiredServiceIds); try { for (const service of rawServices) { - const lifecycle = asString(service.lifecycle, "shared") === "ephemeral" ? "ephemeral" : "shared"; const { scopeType, scopeId } = resolveServiceScopeId({ service, workspace: input.workspace, @@ -1296,13 +1545,15 @@ export async function ensureRuntimeServicesForRun(input: { runId: input.runId, agent: input.agent, }); - const envConfig = parseObject(service.env); - const envFingerprint = createHash("sha256").update(stableStringify(envConfig)).digest("hex"); - const serviceName = asString(service.name, "service"); - const reuseKey = - lifecycle === "shared" - ? [scopeType, scopeId ?? "", serviceName, envFingerprint].join(":") - : null; + const reuseKey = resolveRuntimeServiceReuseIdentity({ + service, + workspace: input.workspace, + agent: input.agent, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType, + scopeId, + }).reuseKey; if (reuseKey) { const existingId = runtimeServicesByReuseKey.get(reuseKey); @@ -1312,6 +1563,10 @@ export async function ensureRuntimeServicesForRun(input: { existing.lastUsedAt = new Date().toISOString(); existing.stoppedAt = null; clearIdleTimer(existing); + void touchLocalServiceRegistryRecord(existing.serviceKey, { + runtimeServiceId: existing.id, + lastSeenAt: existing.lastUsedAt, + }); await persistRuntimeServiceRecord(input.db, existing); acquiredServiceIds.push(existing.id); refs.push(toRuntimeServiceRef(existing, { reused: true })); @@ -1346,6 +1601,83 @@ export async function ensureRuntimeServicesForRun(input: { return refs; } +export async function startRuntimeServicesForWorkspaceControl(input: { + db?: Db; + invocationId?: string; + actor: ExecutionWorkspaceAgentRef; + issue: ExecutionWorkspaceIssueRef | null; + workspace: RealizedExecutionWorkspace; + executionWorkspaceId?: string | null; + config: Record; + adapterEnv: Record; + onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; +}): Promise { + const rawServices = readRuntimeServiceEntries(input.config); + const refs: RuntimeServiceRef[] = []; + const invocationId = input.invocationId ?? randomUUID(); + + for (const service of rawServices) { + const { scopeType, scopeId } = resolveServiceScopeId({ + service, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + issue: input.issue, + runId: invocationId, + agent: input.actor, + }); + const reuseKey = resolveRuntimeServiceReuseIdentity({ + service, + workspace: input.workspace, + agent: input.actor, + issue: input.issue, + adapterEnv: input.adapterEnv, + scopeType, + scopeId, + }).reuseKey; + + if (reuseKey) { + const existingId = runtimeServicesByReuseKey.get(reuseKey); + const existing = existingId ? runtimeServicesById.get(existingId) : null; + if (existing && existing.status === "running") { + existing.lastUsedAt = new Date().toISOString(); + existing.stoppedAt = null; + clearIdleTimer(existing); + void touchLocalServiceRegistryRecord(existing.serviceKey, { + runtimeServiceId: existing.id, + lastSeenAt: existing.lastUsedAt, + }); + await persistRuntimeServiceRecord(input.db, existing); + refs.push(toRuntimeServiceRef(existing, { reused: true })); + continue; + } + } + + // Manually controlled services are not tied to a heartbeat run lifecycle, so they do not + // retain a run lease and never persist a startedByRunId foreign key. + const record = await startLocalRuntimeService({ + db: input.db, + runId: invocationId, + leaseRunId: null, + startedByRunId: null, + agent: input.actor, + issue: input.issue, + workspace: input.workspace, + executionWorkspaceId: input.executionWorkspaceId, + adapterEnv: input.adapterEnv, + service, + onLog: input.onLog, + reuseKey, + scopeType, + scopeId, + }); + registerRuntimeService(input.db, record); + await persistRuntimeServiceRecord(input.db, record); + refs.push(toRuntimeServiceRef(record)); + } + + return refs; +} + export async function releaseRuntimeServicesForRun(runId: string) { const acquired = runtimeServiceLeasesByRun.get(runId) ?? []; runtimeServiceLeasesByRun.delete(runId); @@ -1396,6 +1728,39 @@ export async function stopRuntimeServicesForExecutionWorkspace(input: { } } +export async function stopRuntimeServicesForProjectWorkspace(input: { + db?: Db; + projectWorkspaceId: string; +}) { + const matchingServiceIds = Array.from(runtimeServicesById.values()) + .filter((record) => record.projectWorkspaceId === input.projectWorkspaceId && record.scopeType === "project_workspace") + .map((record) => record.id); + + for (const serviceId of matchingServiceIds) { + await stopRuntimeService(serviceId); + } + + if (input.db) { + const now = new Date(); + await input.db + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where( + and( + eq(workspaceRuntimeServices.projectWorkspaceId, input.projectWorkspaceId), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), + inArray(workspaceRuntimeServices.status, ["starting", "running"]), + ), + ); + } +} + export async function listWorkspaceRuntimeServicesForProjectWorkspaces( db: Db, companyId: string, @@ -1409,6 +1774,7 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces( and( eq(workspaceRuntimeServices.companyId, companyId), inArray(workspaceRuntimeServices.projectWorkspaceId, projectWorkspaceIds), + eq(workspaceRuntimeServices.scopeType, "project_workspace"), ), ) .orderBy(desc(workspaceRuntimeServices.updatedAt), desc(workspaceRuntimeServices.createdAt)); @@ -1424,8 +1790,8 @@ export async function listWorkspaceRuntimeServicesForProjectWorkspaces( } export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { - const staleRows = await db - .select({ id: workspaceRuntimeServices.id }) + const rows = await db + .select() .from(workspaceRuntimeServices) .where( and( @@ -1434,26 +1800,171 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { ), ); - if (staleRows.length === 0) return { reconciled: 0 }; + if (rows.length === 0) return { reconciled: 0, adopted: 0, stopped: 0 }; - const now = new Date(); - await db - .update(workspaceRuntimeServices) - .set({ - status: "stopped", - healthStatus: "unknown", - stoppedAt: now, - lastUsedAt: now, - updatedAt: now, - }) - .where( - and( - eq(workspaceRuntimeServices.provider, "local_process"), - inArray(workspaceRuntimeServices.status, ["starting", "running"]), - ), - ); + let adopted = 0; + let stopped = 0; + for (const row of rows) { + const adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({ + runtimeServiceId: row.id, + profileKind: "workspace-runtime", + }); + if (adoptedRecord) { + const record: RuntimeServiceRecord = { + id: row.id, + companyId: row.companyId, + projectId: row.projectId ?? null, + projectWorkspaceId: row.projectWorkspaceId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + issueId: row.issueId ?? null, + serviceName: row.serviceName, + status: "running", + lifecycle: row.lifecycle as RuntimeServiceRecord["lifecycle"], + scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], + scopeId: row.scopeId ?? null, + reuseKey: row.reuseKey ?? null, + command: row.command ?? null, + cwd: row.cwd ?? null, + port: adoptedRecord.port ?? row.port ?? null, + url: adoptedRecord.url ?? row.url ?? null, + provider: "local_process", + providerRef: String(adoptedRecord.pid), + ownerAgentId: row.ownerAgentId ?? null, + startedByRunId: row.startedByRunId ?? null, + lastUsedAt: new Date().toISOString(), + startedAt: row.startedAt.toISOString(), + stoppedAt: null, + stopPolicy: (row.stopPolicy as Record | null) ?? null, + healthStatus: "healthy", + reused: true, + db, + child: null, + leaseRunIds: new Set(), + idleTimer: null, + envFingerprint: row.reuseKey ?? "", + serviceKey: adoptedRecord.serviceKey, + profileKind: "workspace-runtime", + processGroupId: adoptedRecord.processGroupId ?? null, + }; + registerRuntimeService(db, record); + await touchLocalServiceRegistryRecord(adoptedRecord.serviceKey, { + runtimeServiceId: row.id, + lastSeenAt: record.lastUsedAt, + }); + await persistRuntimeServiceRecord(db, record); + adopted += 1; + continue; + } - return { reconciled: staleRows.length }; + const now = new Date(); + await db + .update(workspaceRuntimeServices) + .set({ + status: "stopped", + healthStatus: "unknown", + stoppedAt: now, + lastUsedAt: now, + updatedAt: now, + }) + .where(eq(workspaceRuntimeServices.id, row.id)); + const registryRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({ + runtimeServiceId: row.id, + profileKind: "workspace-runtime", + }); + if (registryRecord) { + await removeLocalServiceRegistryRecord(registryRecord.serviceKey); + } + stopped += 1; + } + + return { reconciled: rows.length, adopted, stopped }; +} + +export async function restartDesiredRuntimeServicesOnStartup(db: Db) { + let restarted = 0; + let failed = 0; + + const projectWorkspaceRows = await db + .select() + .from(projectWorkspaces); + + for (const row of projectWorkspaceRows) { + const runtimeConfig = readProjectWorkspaceRuntimeConfig((row.metadata as Record | null) ?? null); + if (runtimeConfig?.desiredState !== "running" || !runtimeConfig.workspaceRuntime || !row.cwd) continue; + + try { + const refs = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { id: null, name: "Paperclip", companyId: row.companyId }, + issue: null, + workspace: { + baseCwd: row.cwd, + source: "project_primary", + projectId: row.projectId, + workspaceId: row.id, + repoUrl: row.repoUrl ?? null, + repoRef: row.repoRef ?? null, + strategy: "project_primary", + cwd: row.cwd, + branchName: row.defaultRef ?? row.repoRef ?? null, + worktreePath: null, + warnings: [], + created: false, + }, + config: { workspaceRuntime: runtimeConfig.workspaceRuntime }, + adapterEnv: {}, + }); + if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; + } catch { + failed += 1; + } + } + + const executionWorkspaceRows = await db + .select() + .from(executionWorkspaces) + .where(inArray(executionWorkspaces.status, ["active", "idle", "in_review", "cleanup_failed"])); + + for (const row of executionWorkspaceRows) { + const config = readExecutionWorkspaceConfig((row.metadata as Record | null) ?? null); + if (config?.desiredState !== "running" || !config.workspaceRuntime || !row.cwd) continue; + + try { + const refs = await startRuntimeServicesForWorkspaceControl({ + db, + actor: { id: null, name: "Paperclip", companyId: row.companyId }, + issue: row.sourceIssueId + ? { + id: row.sourceIssueId, + identifier: null, + title: row.name, + } + : null, + workspace: { + baseCwd: row.cwd, + source: row.mode === "shared_workspace" ? "project_primary" : "task_session", + projectId: row.projectId, + workspaceId: row.projectWorkspaceId ?? null, + repoUrl: row.repoUrl ?? null, + repoRef: row.baseRef ?? null, + strategy: row.strategyType === "git_worktree" ? "git_worktree" : "project_primary", + cwd: row.cwd, + branchName: row.branchName ?? null, + worktreePath: row.strategyType === "git_worktree" ? row.cwd : null, + warnings: [], + created: false, + }, + executionWorkspaceId: row.id, + config: { workspaceRuntime: config.workspaceRuntime }, + adapterEnv: {}, + }); + if (refs.length > 0) restarted += refs.filter((ref) => !ref.reused).length; + } catch { + failed += 1; + } + } + + return { restarted, failed }; } export async function persistAdapterManagedRuntimeServices(input: { diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts new file mode 100644 index 00000000..3656b7dc --- /dev/null +++ b/server/src/worktree-config.ts @@ -0,0 +1,467 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { PaperclipConfig } from "@paperclipai/shared"; +import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} + +function sanitizeWorktreeInstanceId(rawValue: string): string { + const trimmed = rawValue.trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } +} + +function parseEnvFile(contents: string): Record { + const entries: Record = {}; + + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + + const [, key, rawValue] = match; + const value = rawValue.trim(); + if (!value) { + entries[key] = ""; + continue; + } + + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + entries[key] = value.slice(1, -1); + continue; + } + + entries[key] = value.replace(/\s+#.*$/, "").trim(); + } + + return entries; +} + +function readEnvEntries(envPath: string): Record { + if (!fs.existsSync(envPath)) return {}; + return parseEnvFile(fs.readFileSync(envPath, "utf8")); +} + +function formatEnvEntries(entries: Record): string { + return [ + "# Paperclip environment variables", + "# Generated by Paperclip worktree repair", + ...Object.entries(entries).map(([key, value]) => `${key}=${JSON.stringify(value)}`), + "", + ].join("\n"); +} + +function isPathInside(candidatePath: string, rootPath: string): boolean { + const candidate = path.resolve(candidatePath); + const root = path.resolve(rootPath); + return candidate === root || candidate.startsWith(`${root}${path.sep}`); +} + +type WorktreeRuntimeContext = { + configPath: string; + envPath: string; + worktreeName: string; + instanceId: string; + homeDir: string; + instanceRoot: string; + contextPath: string; + embeddedPostgresDataDir: string; + backupDir: string; + logDir: string; + storageDir: string; + secretsKeyFilePath: string; +}; + +function resolveWorktreeRuntimeContext( + env: NodeJS.ProcessEnv, + overrideConfigPath?: string, +): WorktreeRuntimeContext | null { + if (env.PAPERCLIP_IN_WORKTREE !== "true") return null; + + const configPath = resolvePaperclipConfigPath(overrideConfigPath); + const envPath = resolvePaperclipEnvPath(configPath); + const worktreeRoot = path.resolve(path.dirname(configPath), ".."); + const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); + const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); + const homeDir = resolveHomeAwarePath( + nonEmpty(env.PAPERCLIP_HOME) ?? + nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? + "~/.paperclip-worktrees", + ); + const instanceRoot = path.resolve(homeDir, "instances", instanceId); + + return { + configPath, + envPath, + worktreeName, + instanceId, + homeDir, + instanceRoot, + contextPath: path.resolve(homeDir, "context.json"), + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + backupDir: path.resolve(instanceRoot, "data", "backups"), + logDir: path.resolve(instanceRoot, "logs"), + storageDir: path.resolve(instanceRoot, "data", "storage"), + secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + }; +} + +function writeConfigFile(configPath: string, config: PaperclipConfig): void { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); +} + +function resolveRepoManagedWorktreesRoot(worktreeRoot: string): string | null { + const normalized = path.resolve(worktreeRoot); + const marker = `${path.sep}.paperclip${path.sep}worktrees${path.sep}`; + const index = normalized.indexOf(marker); + if (index === -1) return null; + const repoRoot = normalized.slice(0, index); + return path.resolve(repoRoot, ".paperclip", "worktrees"); +} + +function collectSiblingWorktreePorts(context: WorktreeRuntimeContext): { + serverPorts: Set; + databasePorts: Set; +} { + const serverPorts = new Set(); + const databasePorts = new Set(); + const siblingConfigPaths = new Set(); + const instancesDir = path.resolve(context.homeDir, "instances"); + if (fs.existsSync(instancesDir)) { + for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === context.instanceId) continue; + + const siblingConfigPath = path.resolve(instancesDir, entry.name, "config.json"); + if (fs.existsSync(siblingConfigPath)) { + siblingConfigPaths.add(siblingConfigPath); + } + } + } + + const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(path.dirname(context.configPath)); + if (repoManagedWorktreesRoot && fs.existsSync(repoManagedWorktreesRoot)) { + for (const entry of fs.readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const siblingConfigPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json"); + if (path.resolve(siblingConfigPath) === path.resolve(context.configPath)) continue; + if (fs.existsSync(siblingConfigPath)) { + siblingConfigPaths.add(siblingConfigPath); + } + } + } + + for (const siblingConfigPath of siblingConfigPaths) { + try { + const siblingConfig = JSON.parse(fs.readFileSync(siblingConfigPath, "utf8")) as PaperclipConfig; + if (Number.isInteger(siblingConfig.server.port) && siblingConfig.server.port > 0) { + serverPorts.add(siblingConfig.server.port); + } + if ( + siblingConfig.database.mode === "embedded-postgres" && + Number.isInteger(siblingConfig.database.embeddedPostgresPort) && + siblingConfig.database.embeddedPostgresPort > 0 + ) { + databasePorts.add(siblingConfig.database.embeddedPostgresPort); + } + } catch { + // Ignore sibling configs that are missing or malformed. + } + } + + return { serverPorts, databasePorts }; +} + +function findNextUnclaimedPort(preferredPort: number, claimedPorts: Set): number { + let port = Math.max(1, Math.trunc(preferredPort)); + while (claimedPorts.has(port)) { + port += 1; + } + return port; +} + +function buildIsolatedWorktreeConfig( + config: PaperclipConfig, + context: WorktreeRuntimeContext, + portOverrides?: { + serverPort?: number; + databasePort?: number; + }, +): PaperclipConfig { + const serverPort = portOverrides?.serverPort ?? config.server.port; + const databasePort = + config.database.mode === "embedded-postgres" + ? portOverrides?.databasePort ?? config.database.embeddedPostgresPort + : undefined; + const nextConfig: PaperclipConfig = { + ...config, + database: { + ...config.database, + ...(config.database.mode === "embedded-postgres" + ? { + embeddedPostgresDataDir: context.embeddedPostgresDataDir, + embeddedPostgresPort: databasePort ?? config.database.embeddedPostgresPort, + backup: { + ...config.database.backup, + dir: context.backupDir, + }, + } + : {}), + }, + server: { + ...config.server, + port: serverPort, + }, + logging: { + ...config.logging, + logDir: context.logDir, + }, + storage: { + ...config.storage, + localDisk: { + ...config.storage.localDisk, + baseDir: context.storageDir, + }, + }, + secrets: { + ...config.secrets, + localEncrypted: { + ...config.secrets.localEncrypted, + keyFilePath: context.secretsKeyFilePath, + }, + }, + }; + + if (config.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { + nextConfig.auth = { + ...config.auth, + publicBaseUrl: rewriteLocalUrlPort(config.auth.publicBaseUrl, serverPort), + }; + } + + return nextConfig; +} + +function needsWorktreeConfigRepair( + config: PaperclipConfig, + context: WorktreeRuntimeContext, +): boolean { + if (config.database.mode === "embedded-postgres") { + if (!isPathInside(config.database.embeddedPostgresDataDir, context.instanceRoot)) { + return true; + } + if (!isPathInside(config.database.backup.dir, context.instanceRoot)) { + return true; + } + } + + if (!isPathInside(config.logging.logDir, context.instanceRoot)) { + return true; + } + if (!isPathInside(config.storage.localDisk.baseDir, context.instanceRoot)) { + return true; + } + if (!isPathInside(config.secrets.localEncrypted.keyFilePath, context.instanceRoot)) { + return true; + } + + return false; +} + +export function applyRuntimePortSelectionToConfig( + config: PaperclipConfig, + input: { + serverPort: number; + databasePort?: number | null; + allowServerPortWrite?: boolean; + allowDatabasePortWrite?: boolean; + }, +): { config: PaperclipConfig; changed: boolean } { + let changed = false; + let nextConfig = config; + + if (input.allowServerPortWrite !== false && config.server.port !== input.serverPort) { + nextConfig = { + ...nextConfig, + server: { + ...nextConfig.server, + port: input.serverPort, + }, + }; + changed = true; + } + + if ( + input.allowDatabasePortWrite !== false && + nextConfig.database.mode === "embedded-postgres" && + typeof input.databasePort === "number" && + nextConfig.database.embeddedPostgresPort !== input.databasePort + ) { + nextConfig = { + ...nextConfig, + database: { + ...nextConfig.database, + embeddedPostgresPort: input.databasePort, + }, + }; + changed = true; + } + + if (nextConfig.auth.baseUrlMode === "explicit" && nextConfig.auth.publicBaseUrl) { + const rewritten = rewriteLocalUrlPort(nextConfig.auth.publicBaseUrl, input.serverPort); + if (rewritten && rewritten !== nextConfig.auth.publicBaseUrl) { + nextConfig = { + ...nextConfig, + auth: { + ...nextConfig.auth, + publicBaseUrl: rewritten, + }, + }; + changed = true; + } + } + + return { config: nextConfig, changed }; +} + +export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { + repairedConfig: boolean; + repairedEnv: boolean; +} { + const context = resolveWorktreeRuntimeContext(process.env); + if (!context) { + return { repairedConfig: false, repairedEnv: false }; + } + + process.env.PAPERCLIP_HOME = context.homeDir; + process.env.PAPERCLIP_INSTANCE_ID = context.instanceId; + process.env.PAPERCLIP_CONFIG = context.configPath; + process.env.PAPERCLIP_CONTEXT = context.contextPath; + process.env.PAPERCLIP_WORKTREE_NAME = context.worktreeName; + + let repairedConfig = false; + if (fs.existsSync(context.configPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; + const siblingPorts = collectSiblingWorktreePorts(context); + const hasSiblingPortCollision = + siblingPorts.serverPorts.has(parsed.server.port) || + (parsed.database.mode === "embedded-postgres" && + siblingPorts.databasePorts.has(parsed.database.embeddedPostgresPort)); + + if (needsWorktreeConfigRepair(parsed, context) || hasSiblingPortCollision) { + const selectedServerPort = findNextUnclaimedPort( + parsed.server.port === 3100 ? 3101 : parsed.server.port, + siblingPorts.serverPorts, + ); + const selectedDatabasePort = + parsed.database.mode === "embedded-postgres" + ? findNextUnclaimedPort( + parsed.database.embeddedPostgresPort === 54329 + ? 54330 + : parsed.database.embeddedPostgresPort, + new Set([...siblingPorts.databasePorts, selectedServerPort]), + ) + : undefined; + + writeConfigFile( + context.configPath, + buildIsolatedWorktreeConfig(parsed, context, { + serverPort: selectedServerPort, + databasePort: selectedDatabasePort, + }), + ); + repairedConfig = true; + } + } catch { + // Leave invalid configs to the normal startup validation path. + } + } + + const existingEnvEntries = readEnvEntries(context.envPath); + const desiredEnvEntries: Record = { + ...existingEnvEntries, + PAPERCLIP_HOME: context.homeDir, + PAPERCLIP_INSTANCE_ID: context.instanceId, + PAPERCLIP_CONFIG: context.configPath, + PAPERCLIP_CONTEXT: context.contextPath, + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: context.worktreeName, + }; + + const repairedEnv = Object.entries(desiredEnvEntries).some( + ([key, value]) => existingEnvEntries[key] !== value, + ); + + if (repairedEnv) { + fs.mkdirSync(path.dirname(context.envPath), { recursive: true }); + fs.writeFileSync(context.envPath, formatEnvEntries(desiredEnvEntries), { mode: 0o600 }); + } + + return { repairedConfig, repairedEnv }; +} + +export function maybePersistWorktreeRuntimePorts(input: { + serverPort: number; + databasePort?: number | null; +}): void { + const context = resolveWorktreeRuntimeContext(process.env); + if (!context || !fs.existsSync(context.configPath)) return; + + let fileConfig: PaperclipConfig; + try { + fileConfig = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; + } catch { + return; + } + + const { config, changed } = applyRuntimePortSelectionToConfig(fileConfig, { + serverPort: input.serverPort, + databasePort: input.databasePort, + allowServerPortWrite: !nonEmpty(process.env.PORT), + allowDatabasePortWrite: !nonEmpty(process.env.DATABASE_URL), + }); + + if (changed) { + writeConfigFile(context.configPath, config); + } +} diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 407f08da..1d319ad3 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -85,7 +85,7 @@ Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`. Priority values: `critical`, `high`, `medium`, `low`. Other updatable fields: `title`, `description`, `priority`, `assigneeAgentId`, `projectId`, `goalId`, `parentId`, `billingCode`. -**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. Set `billingCode` for cross-team work. +**Step 9 — Delegate if needed.** Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. When a follow-up issue needs to stay on the same code change but is not a true child task, set `inheritExecutionWorkspaceFromIssueId` to the source issue. Set `billingCode` for cross-team work. ## Project Setup Workflow (CEO/Manager Common Path) @@ -147,6 +147,7 @@ If you are asked to install a skill for the company or an agent you MUST read: Resolve requesting user id from the triggering comment thread (`authorUserId`) when available; otherwise use the issue's `createdByUserId` if it matches the requester context. - **Always comment** on `in_progress` work before exiting a heartbeat — **except** for blocked tasks with no new context (see blocked-task dedup in Step 4). - **Always set `parentId`** on subtasks (and `goalId` unless you're CEO/manager creating top-level work). +- **Preserve workspace continuity for follow-ups.** Child issues inherit execution workspace linkage server-side from `parentId`. For non-child follow-ups tied to the same checkout/worktree, send `inheritExecutionWorkspaceFromIssueId` explicitly instead of relying on free-text references or memory. - **Never cancel cross-team tasks.** Reassign to your manager with a comment. - **Always update blocked issues explicitly.** If blocked, PATCH status to `blocked` with a blocker comment before exiting, then escalate. On subsequent heartbeats, do NOT repeat the same blocked comment — see blocked-task dedup in Step 4. - **@-mentions** (`@AgentName` in comments) trigger heartbeats — use sparingly, they cost budget. @@ -255,6 +256,7 @@ PATCH /api/agents/{agentId}/instructions-path | ----------------------------------------- | ------------------------------------------------------------------------------------------ | | My identity | `GET /api/agents/me` | | My compact inbox | `GET /api/agents/me/inbox-lite` | +| Report a user's Mine inbox view | `GET /api/agents/me/inbox/mine?userId=:userId` | | My assignments | `GET /api/companies/:companyId/issues?assigneeAgentId=:id&status=todo,in_progress,blocked` | | Checkout task | `POST /api/issues/:issueId/checkout` | | Get task + ancestors | `GET /api/issues/:issueId` | diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 63293725..aea4250c 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -226,6 +226,34 @@ PATCH /api/issues/issue-99 { "comment": "JWT signing done. Still need token refresh logic. Will continue next heartbeat." } ``` +### Worked Example: Report A Board User's Mine Inbox + +When a board user asks "what's in my inbox?", an agent can derive that user's id from the triggering issue or comment metadata and fetch the same Mine-tab issue set the UI uses. + +``` +# Board user created the requesting issue. +GET /api/issues/issue-200 +-> { id: "issue-200", createdByUserId: "user-7", ... } + +# Fetch the board user's Mine inbox issues. +GET /api/agents/me/inbox/mine?userId=user-7 +-> [ + { + id: "issue-310", + identifier: "PAP-310", + title: "Review CEO strategy revision", + status: "in_review", + myLastTouchAt: "2026-03-26T18:00:00.000Z", + lastExternalCommentAt: "2026-03-26T19:10:00.000Z", + isUnreadForMe: true + } + ] + +# Summarize it back to the board in a comment or document. +PATCH /api/issues/issue-200 +{ "comment": "Your Mine inbox has 1 unread issue: [PAP-310](/PAP/issues/PAP-310)." } +``` + --- ## Worked Example: Manager Heartbeat @@ -566,6 +594,7 @@ Terminal states: `done`, `cancelled` | Method | Path | Description | | ------ | ---------------------------------- | ------------------------------------ | | GET | `/api/agents/me` | Your agent record + chain of command | +| GET | `/api/agents/me/inbox/mine?userId=:userId` | Mine-tab issue list for a specific board user | | GET | `/api/agents/:agentId` | Agent details + chain of command | | GET | `/api/companies/:companyId/agents` | List all agents in company | | GET | `/api/companies/:companyId/org` | Org chart tree | diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..0e688669 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,11 @@ +# @paperclipai/ui + +Published static assets for the Paperclip board UI. + +## What gets published + +The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies. + +## Typical use + +Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`. diff --git a/ui/index.html b/ui/index.html index 1bb9152e..70a8550e 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,6 +4,7 @@ + diff --git a/ui/package.json b/ui/package.json index a02ddb12..d344f67a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,13 +1,29 @@ { "name": "@paperclipai/ui", - "version": "0.0.1", - "private": true, + "version": "0.3.1", + "description": "Prebuilt Paperclip board UI assets.", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "ui" + }, "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "typecheck": "tsc -b" + "typecheck": "tsc -b", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs", + "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" + }, + "publishConfig": { + "access": "public" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -25,6 +41,7 @@ "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", + "hermes-paperclip-adapter": "^0.2.0", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 24a97ac0..f240defc 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; +import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { Routines } from "./pages/Routines"; @@ -144,6 +145,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -165,10 +168,11 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -336,7 +340,10 @@ export function App() { } /> } /> } /> + } /> + } /> } /> + } /> } /> }> {boardRoutes()} diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx new file mode 100644 index 00000000..62b85fea --- /dev/null +++ b/ui/src/adapters/hermes-local/config-fields.tsx @@ -0,0 +1,49 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function HermesLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ ); +} diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts new file mode 100644 index 00000000..97c064f8 --- /dev/null +++ b/ui/src/adapters/hermes-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; +import { HermesLocalConfigFields } from "./config-fields"; +import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; + +export const hermesLocalUIAdapter: UIAdapterModule = { + type: "hermes_local", + label: "Hermes Agent", + parseStdoutLine: parseHermesStdoutLine, + ConfigFields: HermesLocalConfigFields, + buildAdapterConfig: buildHermesConfig, +}; diff --git a/ui/src/adapters/opencode-local/config-fields.tsx b/ui/src/adapters/opencode-local/config-fields.tsx index a4ab1d53..4ad7b81f 100644 --- a/ui/src/adapters/opencode-local/config-fields.tsx +++ b/ui/src/adapters/opencode-local/config-fields.tsx @@ -1,7 +1,9 @@ import type { AdapterConfigFieldsProps } from "../types"; import { Field, + ToggleField, DraftInput, + help, } from "../../components/agent-config-primitives"; import { ChoosePathButton } from "../../components/PathInstructionsModal"; @@ -19,31 +21,52 @@ export function OpenCodeLocalConfigFields({ mark, hideInstructionsFile, }: AdapterConfigFieldsProps) { - if (hideInstructionsFile) return null; return ( - -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
-
+ <> + {!hideInstructionsFile && ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ )} + + isCreate + ? set!({ dangerouslySkipPermissions: v }) + : mark("adapterConfig", "dangerouslySkipPermissions", v) + } + /> + ); } diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index fc7be2cf..67d89ada 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; +import { hermesLocalUIAdapter } from "./hermes-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; @@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [ claudeLocalUIAdapter, codexLocalUIAdapter, geminiLocalUIAdapter, + hermesLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 90afd1dd..0a111150 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -4,6 +4,7 @@ import { api } from "./client"; type InviteSummary = { id: string; companyId: string | null; + companyName?: string | null; inviteType: "company_join" | "bootstrap_ceo"; allowedJoinTypes: "human" | "agent" | "both"; expiresAt: string; @@ -87,6 +88,7 @@ type CompanyInviteCreated = { inviteUrl: string; expiresAt: string; allowedJoinTypes: "human" | "agent" | "both"; + companyName?: string | null; onboardingTextPath?: string; onboardingTextUrl?: string; inviteMessage?: string | null; diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index ccaf15c0..ec090b43 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -27,6 +27,12 @@ export interface AdapterModel { label: string; } +export interface DetectedAdapterModel { + model: string; + provider: string; + source: string; +} + export interface ClaudeLoginResult { exitCode: number | null; signal: string | null; @@ -159,6 +165,10 @@ export const agentsApi = { api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, ), + detectModel: (companyId: string, type: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/api/execution-workspaces.ts b/ui/src/api/execution-workspaces.ts index bf83999c..3644af77 100644 --- a/ui/src/api/execution-workspaces.ts +++ b/ui/src/api/execution-workspaces.ts @@ -1,4 +1,4 @@ -import type { ExecutionWorkspace } from "@paperclipai/shared"; +import type { ExecutionWorkspace, ExecutionWorkspaceCloseReadiness, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; export const executionWorkspacesApi = { @@ -22,5 +22,14 @@ export const executionWorkspacesApi = { return api.get(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`); }, get: (id: string) => api.get(`/execution-workspaces/${id}`), + getCloseReadiness: (id: string) => + api.get(`/execution-workspaces/${id}/close-readiness`), + listWorkspaceOperations: (id: string) => + api.get(`/execution-workspaces/${id}/workspace-operations`), + controlRuntimeServices: (id: string, action: "start" | "stop" | "restart") => + api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>( + `/execution-workspaces/${id}/runtime-services/${action}`, + {}, + ), update: (id: string, data: Record) => api.patch(`/execution-workspaces/${id}`, data), }; diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 5e5d966e..7f0b2b27 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -11,6 +11,10 @@ import type { } from "@paperclipai/shared"; import { api } from "./client"; +export type IssueUpdateResponse = Issue & { + comment?: IssueComment | null; +}; + export const issuesApi = { list: ( companyId: string, @@ -21,8 +25,10 @@ export const issuesApi = { participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; + inboxArchivedByUserId?: string; unreadForUserId?: string; labelId?: string; + executionWorkspaceId?: string; originKind?: string; originId?: string; includeRoutineExecutions?: boolean; @@ -36,8 +42,10 @@ export const issuesApi = { if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId); + if (filters?.inboxArchivedByUserId) params.set("inboxArchivedByUserId", filters.inboxArchivedByUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); if (filters?.labelId) params.set("labelId", filters.labelId); + if (filters?.executionWorkspaceId) params.set("executionWorkspaceId", filters.executionWorkspaceId); if (filters?.originKind) params.set("originKind", filters.originKind); if (filters?.originId) params.set("originId", filters.originId); if (filters?.includeRoutineExecutions) params.set("includeRoutineExecutions", "true"); @@ -51,9 +59,15 @@ export const issuesApi = { deleteLabel: (id: string) => api.delete(`/labels/${id}`), get: (id: string) => api.get(`/issues/${id}`), markRead: (id: string) => api.post<{ id: string; lastReadAt: Date }>(`/issues/${id}/read`, {}), + markUnread: (id: string) => api.delete<{ id: string; removed: boolean }>(`/issues/${id}/read`), + archiveFromInbox: (id: string) => + api.post<{ id: string; archivedAt: Date }>(`/issues/${id}/inbox-archive`, {}), + unarchiveFromInbox: (id: string) => + api.delete<{ id: string; archivedAt: Date } | { ok: true }>(`/issues/${id}/inbox-archive`), create: (companyId: string, data: Record) => api.post(`/companies/${companyId}/issues`, data), - update: (id: string, data: Record) => api.patch(`/issues/${id}`, data), + update: (id: string, data: Record) => + api.patch(`/issues/${id}`, data), remove: (id: string) => api.delete(`/issues/${id}`), checkout: (id: string, agentId: string) => api.post(`/issues/${id}/checkout`, { diff --git a/ui/src/api/projects.ts b/ui/src/api/projects.ts index c7177ac6..763718ff 100644 --- a/ui/src/api/projects.ts +++ b/ui/src/api/projects.ts @@ -1,4 +1,4 @@ -import type { Project, ProjectWorkspace } from "@paperclipai/shared"; +import type { Project, ProjectWorkspace, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; function withCompanyScope(path: string, companyId?: string) { @@ -27,6 +27,16 @@ export const projectsApi = { projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`), data, ), + controlWorkspaceRuntimeServices: ( + projectId: string, + workspaceId: string, + action: "start" | "stop" | "restart", + companyId?: string, + ) => + api.post<{ workspace: ProjectWorkspace; operation: WorkspaceOperation }>( + projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}/runtime-services/${action}`), + {}, + ), removeWorkspace: (projectId: string, workspaceId: string, companyId?: string) => api.delete(projectPath(projectId, companyId, `/workspaces/${encodeURIComponent(workspaceId)}`)), remove: (id: string, companyId?: string) => api.delete(projectPath(id, companyId)), diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 1810e9a8..06c74e65 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -140,6 +140,7 @@ const codexThinkingEffortOptions = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, + { id: "xhigh", label: "X-High" }, ] as const; const openCodeThinkingEffortOptions = [ @@ -148,6 +149,7 @@ const openCodeThinkingEffortOptions = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, + { id: "xhigh", label: "X-High" }, { id: "max", label: "Max" }, ] as const; @@ -248,9 +250,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } if (overlay.adapterType !== undefined) { patch.adapterType = overlay.adapterType; - // When adapter type changes, send only the new config — don't merge - // with old config since old adapter fields are meaningless for the new type - patch.adapterConfig = overlay.adapterConfig; + // When adapter type changes, replace adapter-specific fields but preserve + // adapter-agnostic fields (env, promptTemplate, etc.) that are shared + // across all adapter types. + const existing = (agent.adapterConfig ?? {}) as Record; + const adapterAgnosticKeys = [ + "env", + "promptTemplate", + "instructionsFilePath", + "cwd", + "timeoutSec", + "graceSec", + "bootstrapPromptTemplate", + ]; + const preserved: Record = {}; + for (const key of adapterAgnosticKeys) { + if (key in existing) { + preserved[key] = existing[key]; + } + } + patch.adapterConfig = { ...preserved, ...overlay.adapterConfig }; } else if (Object.keys(overlay.adapterConfig).length > 0) { const existing = (agent.adapterConfig ?? {}) as Record; patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; @@ -296,9 +315,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; + const isHermesLocal = adapterType === "hermes_local"; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); @@ -315,6 +336,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) { enabled: Boolean(selectedCompanyId), }); const models = fetchedModels ?? externalModels ?? []; + const { + data: detectedModelData, + refetch: refetchDetectedModel, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.detectModel(selectedCompanyId, adapterType) + : ["agents", "none", "detect-model", adapterType], + queryFn: () => { + if (!selectedCompanyId) { + throw new Error("Select a company to detect the Hermes model"); + } + return agentsApi.detectModel(selectedCompanyId, adapterType); + }, + enabled: Boolean(selectedCompanyId && isHermesLocal), + }); + const detectedModel = detectedModelData?.model ?? null; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], @@ -688,6 +725,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "codex" : adapterType === "gemini_local" ? "gemini" + : adapterType === "hermes_local" + ? "hermes" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" @@ -709,9 +748,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} - allowDefault={adapterType !== "opencode_local"} - required={adapterType === "opencode_local"} + allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"} + required={adapterType === "opencode_local" || adapterType === "hermes_local"} groupByProvider={adapterType === "opencode_local"} + creatable={adapterType === "hermes_local"} + detectedModel={adapterType === "hermes_local" ? detectedModel : null} + onDetectModel={adapterType === "hermes_local" + ? async () => { + const result = await refetchDetectedModel(); + return result.data?.model ?? null; + } + : undefined} + detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined} /> {fetchedModelsError && (

@@ -976,7 +1024,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe /* ---- Internal sub-components ---- */ -const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); +const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); /** Display list includes all real adapter types plus UI-only coming-soon entries. */ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ @@ -1293,6 +1341,10 @@ function ModelDropdown({ allowDefault, required, groupByProvider, + creatable, + detectedModel, + onDetectModel, + detectModelLabel, }: { models: AdapterModel[]; value: string; @@ -1302,9 +1354,20 @@ function ModelDropdown({ allowDefault: boolean; required: boolean; groupByProvider: boolean; + creatable?: boolean; + detectedModel?: string | null; + onDetectModel?: () => Promise; + detectModelLabel?: string; }) { const [modelSearch, setModelSearch] = useState(""); + const [detectingModel, setDetectingModel] = useState(false); const selected = models.find((m) => m.id === value); + const manualModel = modelSearch.trim(); + const canCreateManualModel = Boolean( + creatable && + manualModel && + !models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()), + ); const filteredModels = useMemo(() => { return models.filter((m) => { if (!modelSearch.trim()) return true; @@ -1341,6 +1404,21 @@ function ModelDropdown({ })); }, [filteredModels, groupByProvider]); + async function handleDetectModel() { + if (!onDetectModel) return; + setDetectingModel(true); + try { + const nextModel = await onDetectModel(); + if (nextModel) { + onChange(nextModel); + onOpenChange(false); + setModelSearch(""); + } + } finally { + setDetectingModel(false); + } + } + return ( - - setModelSearch(e.target.value)} - autoFocus - /> +

+ setModelSearch(e.target.value)} + autoFocus + /> + {modelSearch && ( + + )} +
+ {onDetectModel && !detectedModel && !modelSearch.trim() && ( + + )} + {value && !models.some((m) => m.id === value) && ( + + )} + {detectedModel && detectedModel !== value && ( + + )}
{allowDefault && ( + )} {groupedModels.map((group) => (
{groupByProvider && ( @@ -1392,6 +1552,7 @@ function ModelDropdown({ )} {group.entries.map((m) => (
diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index cdf0ddd2..2501d95a 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -10,11 +10,16 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma import { StatusBadge } from "./StatusBadge"; import { AgentIcon } from "./AgentIconPicker"; import { formatDateTime } from "../lib/utils"; +import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { PluginSlotOutlet } from "@/plugins/slots"; interface CommentWithRunMeta extends IssueComment { runId?: string | null; runAgentId?: string | null; + clientId?: string; + clientStatus?: "pending" | "queued"; + queueState?: "queued"; + queueTargetRunId?: string | null; } interface LinkedRunItem { @@ -32,6 +37,7 @@ interface CommentReassignment { interface CommentThreadProps { comments: CommentWithRunMeta[]; + queuedComments?: CommentWithRunMeta[]; linkedRuns?: LinkedRunItem[]; companyId?: string | null; projectId?: string | null; @@ -48,6 +54,8 @@ interface CommentThreadProps { currentAssigneeValue?: string; suggestedAssigneeValue?: string; mentions?: MentionOption[]; + onInterruptQueued?: (runId: string) => Promise; + interruptingQueuedRunId?: string | null; } const DRAFT_DEBOUNCE_MS = 800; @@ -114,6 +122,122 @@ function CopyMarkdownButton({ text }: { text: string }) { ); } +function CommentCard({ + comment, + agentMap, + companyId, + projectId, + highlightCommentId, + queued = false, +}: { + comment: CommentWithRunMeta; + agentMap?: Map; + companyId?: string | null; + projectId?: string | null; + highlightCommentId?: string | null; + queued?: boolean; +}) { + const isHighlighted = highlightCommentId === comment.id; + const isPending = comment.clientStatus === "pending"; + const isQueued = queued || comment.queueState === "queued" || comment.clientStatus === "queued"; + + return ( +
+
+ {comment.authorAgentId ? ( + + + + ) : ( + + )} + + {isQueued ? ( + + Queued + + ) : null} + {companyId && !isPending ? ( + + ) : null} + {isPending ? ( + {isQueued ? "Queueing..." : "Sending..."} + ) : ( + + {formatDateTime(comment.createdAt)} + + )} + + +
+ {comment.body} + {companyId && !isPending ? ( +
+ +
+ ) : null} + {comment.runId && !isPending ? ( +
+ {comment.runAgentId ? ( + + run {comment.runId.slice(0, 8)} + + ) : ( + + run {comment.runId.slice(0, 8)} + + )} +
+ ) : null} +
+ ); +} + type TimelineItem = | { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta } | { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem }; @@ -168,86 +292,15 @@ const TimelineList = memo(function TimelineList({ } const comment = item.comment; - const isHighlighted = highlightCommentId === comment.id; return ( -
-
- {comment.authorAgentId ? ( - - - - ) : ( - - )} - - {companyId ? ( - - ) : null} - - {formatDateTime(comment.createdAt)} - - - -
- {comment.body} - {companyId ? ( -
- -
- ) : null} - {comment.runId && ( -
- {comment.runAgentId ? ( - - run {comment.runId.slice(0, 8)} - - ) : ( - - run {comment.runId.slice(0, 8)} - - )} -
- )} -
+ comment={comment} + agentMap={agentMap} + companyId={companyId} + projectId={projectId} + highlightCommentId={highlightCommentId} + /> ); })}
@@ -256,6 +309,7 @@ const TimelineList = memo(function TimelineList({ export function CommentThread({ comments, + queuedComments = [], linkedRuns = [], companyId, projectId, @@ -270,6 +324,8 @@ export function CommentThread({ currentAssigneeValue = "", suggestedAssigneeValue, mentions: providedMentions, + onInterruptQueued, + interruptingQueuedRunId = null, }: CommentThreadProps) { const [body, setBody] = useState(""); const [reopen, setReopen] = useState(true); @@ -345,7 +401,7 @@ export function CommentThread({ // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; - if (!hash.startsWith("#comment-") || comments.length === 0) return; + if (!hash.startsWith("#comment-") || comments.length + queuedComments.length === 0) return; const commentId = hash.slice("#comment-".length); // Only scroll once per hash if (hasScrolledRef.current) return; @@ -358,21 +414,31 @@ export function CommentThread({ const timer = setTimeout(() => setHighlightCommentId(null), 3000); return () => clearTimeout(timer); } - }, [location.hash, comments]); + }, [location.hash, comments, queuedComments]); async function handleSubmit() { const trimmed = body.trim(); if (!trimmed) return; const hasReassignment = enableReassign && reassignTarget !== currentAssigneeValue; const reassignment = hasReassignment ? parseReassignment(reassignTarget) : null; + const submittedBody = trimmed; setSubmitting(true); + setBody(""); try { - await onAdd(trimmed, reopen ? true : undefined, reassignment ?? undefined); - setBody(""); + // TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI. + await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); setReopen(true); setReassignTarget(effectiveSuggestedAssigneeValue); + } catch { + setBody((current) => + restoreSubmittedCommentDraft({ + currentBody: current, + submittedBody, + }), + ); + // Parent mutation handlers surface the failure and the draft is restored for retry. } finally { setSubmitting(false); } @@ -401,18 +467,54 @@ export function CommentThread({ return (
-

Comments & Runs ({timeline.length})

+

Comments & Runs ({timeline.length + queuedComments.length})

- + {timeline.length > 0 ? ( + + ) : null} {liveRunSlot} + {queuedComments.length > 0 && ( +
+
+

+ Queued Comments ({queuedComments.length}) +

+ {onInterruptQueued && queuedComments[0]?.queueTargetRunId ? ( + + ) : null} +
+
+ {queuedComments.map((comment) => ( + + ))} +
+
+ )} +
void; + onClosed?: (workspace: ExecutionWorkspace) => void; +}; + +function readinessTone(state: "ready" | "ready_with_warnings" | "blocked") { + if (state === "blocked") { + return "border-destructive/30 bg-destructive/5 text-destructive"; + } + if (state === "ready_with_warnings") { + return "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:text-amber-300"; + } + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; +} + +export function ExecutionWorkspaceCloseDialog({ + workspaceId, + workspaceName, + currentStatus, + open, + onOpenChange, + onClosed, +}: ExecutionWorkspaceCloseDialogProps) { + const queryClient = useQueryClient(); + const { pushToast } = useToast(); + const actionLabel = currentStatus === "cleanup_failed" ? "Retry close" : "Close workspace"; + + const readinessQuery = useQuery({ + queryKey: queryKeys.executionWorkspaces.closeReadiness(workspaceId), + queryFn: () => executionWorkspacesApi.getCloseReadiness(workspaceId), + enabled: open, + }); + + const closeWorkspace = useMutation({ + mutationFn: () => executionWorkspacesApi.update(workspaceId, { status: "archived" }), + onSuccess: (workspace) => { + queryClient.setQueryData(queryKeys.executionWorkspaces.detail(workspace.id), workspace); + queryClient.invalidateQueries({ queryKey: queryKeys.executionWorkspaces.closeReadiness(workspace.id) }); + pushToast({ + title: currentStatus === "cleanup_failed" ? "Workspace close retried" : "Workspace closed", + tone: "success", + }); + onOpenChange(false); + onClosed?.(workspace); + }, + onError: (error) => { + pushToast({ + title: "Failed to close workspace", + body: error instanceof Error ? error.message : "Unknown error", + tone: "error", + }); + }, + }); + + const readiness = readinessQuery.data ?? null; + const blockingIssues = readiness?.linkedIssues.filter((issue) => !issue.isTerminal) ?? []; + const otherLinkedIssues = readiness?.linkedIssues.filter((issue) => issue.isTerminal) ?? []; + const confirmDisabled = + currentStatus === "archived" || + closeWorkspace.isPending || + readinessQuery.isLoading || + readiness == null || + readiness.state === "blocked"; + + return ( + { + if (!closeWorkspace.isPending) onOpenChange(nextOpen); + }}> + + + {actionLabel} + + Archive {workspaceName} and clean up any owned workspace + artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views. + + + + {readinessQuery.isLoading ? ( +
+ + Checking whether this workspace is safe to close... +
+ ) : readinessQuery.error ? ( +
+ {readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."} +
+ ) : readiness ? ( +
+
+
+ {readiness.state === "blocked" + ? "Close is blocked" + : readiness.state === "ready_with_warnings" + ? "Close is allowed with warnings" + : "Close is ready"} +
+
+ {readiness.isSharedWorkspace + ? "This is a shared workspace session. Archiving it removes this session record but keeps the underlying project workspace." + : readiness.git?.workspacePath && readiness.git.repoRoot && readiness.git.workspacePath !== readiness.git.repoRoot + ? "This execution workspace has its own checkout path and can be archived independently." + : readiness.isProjectPrimaryWorkspace + ? "This execution workspace currently points at the project's primary workspace path." + : "This workspace is disposable and can be archived."} +
+
+ + {blockingIssues.length > 0 ? ( +
+

Blocking issues

+
+ {blockingIssues.map((issue) => ( +
+
+ + {issue.identifier ?? issue.id} · {issue.title} + + {issue.status} +
+
+ ))} +
+
+ ) : null} + + {readiness.blockingReasons.length > 0 ? ( +
+

Blocking reasons

+
    + {readiness.blockingReasons.map((reason, idx) => ( +
  • + {reason} +
  • + ))} +
+
+ ) : null} + + {readiness.warnings.length > 0 ? ( +
+

Warnings

+
    + {readiness.warnings.map((warning, idx) => ( +
  • + {warning} +
  • + ))} +
+
+ ) : null} + + {readiness.git ? ( +
+

Git status

+
+
+
+
Branch
+
{readiness.git.branchName ?? "Unknown"}
+
+
+
Base ref
+
{readiness.git.baseRef ?? "Not set"}
+
+
+
Merged into base
+
{readiness.git.isMergedIntoBase == null ? "Unknown" : readiness.git.isMergedIntoBase ? "Yes" : "No"}
+
+
+
Ahead / behind
+
+ {(readiness.git.aheadCount ?? 0).toString()} / {(readiness.git.behindCount ?? 0).toString()} +
+
+
+
Dirty tracked files
+
{readiness.git.dirtyEntryCount}
+
+
+
Untracked files
+
{readiness.git.untrackedEntryCount}
+
+
+
+
+ ) : null} + + {otherLinkedIssues.length > 0 ? ( +
+

Other linked issues

+
+ {otherLinkedIssues.map((issue) => ( +
+
+ + {issue.identifier ?? issue.id} · {issue.title} + + {issue.status} +
+
+ ))} +
+
+ ) : null} + + {readiness.runtimeServices.length > 0 ? ( +
+

Attached runtime services

+
+ {readiness.runtimeServices.map((service) => ( +
+
+ {service.serviceName} + {service.status} · {service.lifecycle} +
+
+ {service.url ?? service.command ?? service.cwd ?? "No additional details"} +
+
+ ))} +
+
+ ) : null} + +
+

Cleanup actions

+
+ {readiness.plannedActions.map((action, index) => ( +
+
{action.label}
+
{action.description}
+ {action.command ? ( +
+                        {action.command}
+                      
+ ) : null} +
+ ))} +
+
+ + {currentStatus === "cleanup_failed" ? ( +
+ Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the + workspace status if it succeeds. +
+ ) : null} + + {currentStatus === "archived" ? ( +
+ This workspace is already archived. +
+ ) : null} + + {readiness.git?.repoRoot ? ( +
+ Repo root: {readiness.git.repoRoot} + {readiness.git.workspacePath ? ( + <> + {" · "}Workspace path: {readiness.git.workspacePath} + + ) : null} +
+ ) : null} + +
+ Last checked {formatDateTime(new Date())} +
+
+ ) : null} + + + + + +
+
+ ); +} diff --git a/ui/src/components/HermesIcon.tsx b/ui/src/components/HermesIcon.tsx new file mode 100644 index 00000000..fb02623a --- /dev/null +++ b/ui/src/components/HermesIcon.tsx @@ -0,0 +1,43 @@ +import { cn } from "../lib/utils"; + +interface HermesIconProps { + className?: string; +} + +/** + * Hermes caduceus icon — winged staff with two intertwined serpents. + * Replaces the generic Zap icon for the hermes_local adapter type. + * + * ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings. + */ +export function HermesIcon({ className }: HermesIconProps) { + return ( + + {/* Central staff */} + + {/* Left serpent curves */} + + {/* Right serpent curves */} + + {/* Snake heads facing outward */} + + + {/* Wings at top of staff */} + + + {/* Wing feather details */} + + + {/* Staff sphere at top */} + + + ); +} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 3d12e9e3..ced81b23 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,12 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; import type { Issue } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; -import { executionWorkspacesApi } from "../api/execution-workspaces"; -import { instanceSettingsApi } from "../api/instanceSettings"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; @@ -21,15 +19,9 @@ import { formatDate, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, Copy, Check } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; -const EXECUTION_WORKSPACE_OPTIONS = [ - { value: "shared_workspace", label: "Project default" }, - { value: "isolated_workspace", label: "New isolated workspace" }, - { value: "reuse_existing", label: "Reuse existing workspace" }, -] as const; - function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null; @@ -48,23 +40,6 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo return "shared_workspace"; } -function issueModeForExistingWorkspace(mode: string | null | undefined) { - if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode; - if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default"; - return "shared_workspace"; -} - -function shouldPresentExistingWorkspaceSelection(issue: Issue) { - const persistedMode = - issue.currentExecutionWorkspace?.mode - ?? issue.executionWorkspaceSettings?.mode - ?? issue.executionWorkspacePreference; - return Boolean( - issue.executionWorkspaceId && - (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), - ); -} - interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; @@ -142,49 +117,6 @@ function PropertyPicker({ ); } -/** Splits a string at `/` and `-` boundaries, inserting for natural line breaks. */ -function BreakablePath({ text }: { text: string }) { - const parts: React.ReactNode[] = []; - // Split on path separators and hyphens, keeping them in the output - const segments = text.split(/(?<=[\/-])/); - for (let i = 0; i < segments.length; i++) { - if (i > 0) parts.push(); - parts.push(segments[i]); - } - return <>{parts}; -} - -/** Displays a value with a copy-to-clipboard icon and "Copied!" feedback. */ -function CopyableValue({ value, label, mono, className }: { value: string; label?: string; mono?: boolean; className?: string }) { - const [copied, setCopied] = useState(false); - const timerRef = useRef>(undefined); - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setCopied(false), 1500); - } catch { /* noop */ } - }, [value]); - - return ( -
- - {label && {label} } - - - -
- ); -} - export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -202,10 +134,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); - const { data: experimentalSettings } = useQuery({ - queryKey: queryKeys.instance.experimentalSettings, - queryFn: () => instanceSettingsApi.getExperimental(), - }); const currentUserId = session?.user?.id ?? session?.session?.userId; const { data: agents } = useQuery({ @@ -275,48 +203,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp const currentProject = issue.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? null : null; - const currentProjectExecutionWorkspacePolicy = - experimentalSettings?.enableIsolatedWorkspaces === true - ? currentProject?.executionWorkspacePolicy ?? null - : null; - const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); - const { data: reusableExecutionWorkspaces } = useQuery({ - queryKey: queryKeys.executionWorkspaces.list(companyId!, { - projectId: issue.projectId ?? undefined, - projectWorkspaceId: issue.projectWorkspaceId ?? undefined, - reuseEligible: true, - }), - queryFn: () => - executionWorkspacesApi.list(companyId!, { - projectId: issue.projectId ?? undefined, - projectWorkspaceId: issue.projectWorkspaceId ?? undefined, - reuseEligible: true, - }), - enabled: Boolean(companyId) && Boolean(issue.projectId), - }); - const deduplicatedReusableWorkspaces = useMemo(() => { - const workspaces = reusableExecutionWorkspaces ?? []; - const seen = new Map(); - for (const ws of workspaces) { - const key = ws.cwd ?? ws.id; - const existing = seen.get(key); - if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) { - seen.set(key, ws); - } - } - return Array.from(seen.values()); - }, [reusableExecutionWorkspaces]); - const selectedReusableExecutionWorkspace = - deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId) - ?? issue.currentExecutionWorkspace - ?? null; - const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue) - ? "reuse_existing" - : ( - issue.executionWorkspacePreference - ?? issue.executionWorkspaceSettings?.mode - ?? defaultExecutionWorkspaceModeForProject(currentProject) - ); const projectLink = (id: string | null) => { if (!id) return null; const project = projects?.find((p) => p.id === id) ?? null; @@ -674,93 +560,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp {projectContent} - {currentProjectSupportsExecutionWorkspace && ( - -
- - - {currentExecutionWorkspaceSelection === "reuse_existing" && ( - - )} - - {issue.currentExecutionWorkspace && ( -
-
- Current:{" "} - - - - {" · "} - {issue.currentExecutionWorkspace.status} -
- {issue.currentExecutionWorkspace.cwd && ( - - )} - {issue.currentExecutionWorkspace.branchName && ( - - )} - {issue.currentExecutionWorkspace.repoUrl && ( - - )} -
- )} - {!issue.currentExecutionWorkspace && currentProject?.primaryWorkspace?.cwd && ( - - )} -
-
- )} - {issue.parentId && ( ({ + Link: ({ children, className, ...props }: React.ComponentProps<"a">) => ( + {children} + ), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createIssue(overrides: Partial = {}): Issue { + return { + id: "issue-1", + identifier: "PAP-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Inbox item", + description: null, + status: "todo", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + createdByAgentId: null, + createdByUserId: null, + issueNumber: 1, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-11T00:00:00.000Z"), + updatedAt: new Date("2026-03-11T00:00:00.000Z"), + labels: [], + labelIds: [], + myLastTouchAt: null, + lastExternalCommentAt: null, + isUnreadForMe: false, + ...overrides, + }; +} + +describe("IssueRow", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + container.remove(); + }); + + it("suppresses accent hover styling when the row is selected", () => { + const root = createRoot(container); + const issue = createIssue(); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.className).toContain("hover:bg-transparent"); + expect(link?.className).not.toContain("hover:bg-accent/50"); + + act(() => { + root.unmount(); + }); + }); + + it("neutralizes selected status and unread dot accents", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const markReadButton = container.querySelector('button[aria-label="Mark as read"]'); + const unreadDot = markReadButton?.querySelector("span"); + const statusIcon = container.querySelector('span[class*="border-muted-foreground"]'); + + expect(markReadButton).not.toBeNull(); + expect(markReadButton?.className).toContain("hover:bg-muted/80"); + expect(markReadButton?.className).not.toContain("hover:bg-blue-500/20"); + expect(unreadDot).not.toBeNull(); + expect(unreadDot?.className).toContain("bg-muted-foreground/70"); + expect(unreadDot?.className).not.toContain("bg-blue-600"); + expect(statusIcon).not.toBeNull(); + expect(statusIcon?.className).toContain("!border-muted-foreground"); + expect(statusIcon?.className).toContain("!text-muted-foreground"); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index ee351d57..8a01e585 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; +import { X } from "lucide-react"; +import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -9,6 +11,7 @@ type UnreadState = "hidden" | "visible" | "fading"; interface IssueRowProps { issue: Issue; issueLinkState?: unknown; + selected?: boolean; mobileLeading?: ReactNode; desktopMetaLeading?: ReactNode; desktopLeadingSpacer?: boolean; @@ -17,12 +20,15 @@ interface IssueRowProps { trailingMeta?: ReactNode; unreadState?: UnreadState | null; onMarkRead?: () => void; + onArchive?: () => void; + archiveDisabled?: boolean; className?: string; } export function IssueRow({ issue, issueLinkState, + selected = false, mobileLeading, desktopMetaLeading, desktopLeadingSpacer = false, @@ -31,24 +37,29 @@ export function IssueRow({ trailingMeta, unreadState = null, onMarkRead, + onArchive, + archiveDisabled, className, }: IssueRowProps) { const issuePathId = issue.identifier ?? issue.id; const identifier = issue.identifier ?? issue.id.slice(0, 8); const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; + const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; return ( - {mobileLeading ?? } + {mobileLeading ?? } @@ -61,7 +72,7 @@ export function IssueRow({ {desktopMetaLeading ?? ( <> - + {identifier} @@ -103,16 +114,40 @@ export function IssueRow({ onMarkRead?.(); } }} - className="inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors hover:bg-blue-500/20" + className={cn( + "inline-flex h-4 w-4 items-center justify-center rounded-full transition-colors", + selected ? "hover:bg-muted/80" : "hover:bg-blue-500/20", + )} aria-label="Mark as read" > + ) : onArchive ? ( + ) : (
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 342a74de..e04ceda2 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -8,6 +8,7 @@ import { useState, type DragEvent, } from "react"; +import { createPortal } from "react-dom"; import { CodeMirrorEditor, MDXEditor, @@ -82,6 +83,9 @@ interface MentionState { query: string; top: number; left: number; + /** Viewport-relative coords for portal positioning */ + viewportTop: number; + viewportLeft: number; textNode: Text; atPos: number; endPos: number; @@ -155,6 +159,8 @@ function detectMention(container: HTMLElement): MentionState | null { query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, + viewportTop: rect.bottom, + viewportLeft: rect.left, textNode: textNode as Text, atPos, endPos: offset, @@ -554,46 +560,51 @@ export const MarkdownEditor = forwardRef plugins={plugins} /> - {/* Mention dropdown */} - {mentionActive && filteredMentions.length > 0 && ( -
- {filteredMentions.map((option, i) => ( - - ))} -
- )} + {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */} + {mentionActive && filteredMentions.length > 0 && + createPortal( +
+ {filteredMentions.map((option, i) => ( + + ))} +
, + document.body, + )} {isDragOver && canDropImage && (
{ queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) }); diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 4561ac93..afdb057a 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -1,8 +1,9 @@ -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { projectsApi } from "../api/projects"; +import { agentsApi } from "../api/agents"; import { goalsApi } from "../api/goals"; import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; @@ -32,7 +33,7 @@ import { } from "@/components/ui/tooltip"; import { PROJECT_COLORS } from "@paperclipai/shared"; import { cn } from "../lib/utils"; -import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; +import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; import { ChoosePathButton } from "./PathInstructionsModal"; @@ -68,6 +69,29 @@ export function NewProjectDialog() { enabled: !!selectedCompanyId && newProjectOpen, }); + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && newProjectOpen, + }); + + const mentionOptions = useMemo(() => { + const options: MentionOption[] = []; + const activeAgents = [...(agents ?? [])] + .filter((agent) => agent.status !== "terminated") + .sort((a, b) => a.name.localeCompare(b.name)); + for (const agent of activeAgents) { + options.push({ + id: `agent:${agent.id}`, + name: agent.name, + kind: "agent", + agentId: agent.id, + agentIcon: agent.icon, + }); + } + return options; + }, [agents]); + const createProject = useMutation({ mutationFn: (data: Record) => projectsApi.create(selectedCompanyId!, data), @@ -250,6 +274,7 @@ export function NewProjectDialog() { onChange={setDescription} placeholder="Add description..." bordered={false} + mentions={mentionOptions} contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")} imageUploadHandler={async (file) => { const asset = await uploadDescriptionImage.mutateAsync(file); diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 06a053db..b3ec724e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,12 +56,14 @@ import { ChevronDown, X } from "lucide-react"; +import { HermesIcon } from "./HermesIcon"; type Step = 1 | 2 | 3 | 4; type AdapterType = | "claude_local" | "codex_local" | "gemini_local" + | "hermes_local" | "opencode_local" | "pi_local" | "cursor" @@ -208,6 +210,7 @@ export function OnboardingWizard() { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; @@ -217,6 +220,8 @@ export function OnboardingWizard() { ? "codex" : adapterType === "gemini_local" ? "gemini" + : adapterType === "hermes_local" + ? "hermes" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" @@ -325,7 +330,8 @@ export function OnboardingWizard() { command, args, url, - dangerouslySkipPermissions: adapterType === "claude_local", + dangerouslySkipPermissions: + adapterType === "claude_local" || adapterType === "opencode_local", dangerouslyBypassSandbox: adapterType === "codex_local" ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX @@ -842,6 +848,12 @@ export function OnboardingWizard() { icon: MousePointer2, desc: "Local Cursor agent" }, + { + value: "hermes_local" as const, + label: "Hermes Agent", + icon: HermesIcon, + desc: "Local multi-provider agent" + }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", @@ -901,6 +913,7 @@ export function OnboardingWizard() { {(adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor") && ( diff --git a/ui/src/components/SidebarProjects.tsx b/ui/src/components/SidebarProjects.tsx index cc6f417c..3c63ebc3 100644 --- a/ui/src/components/SidebarProjects.tsx +++ b/ui/src/components/SidebarProjects.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { ChevronRight, Plus } from "lucide-react"; import { DndContext, - PointerSensor, + MouseSensor, closestCenter, type DragEndEvent, useSensor, @@ -153,7 +153,8 @@ export function SidebarProjects() { const projectMatch = location.pathname.match(/^\/(?:[^/]+\/)?projects\/([^/]+)/); const activeProjectRef = projectMatch?.[1] ?? null; const sensors = useSensors( - useSensor(PointerSensor, { + // Project reordering is intentionally desktop-only; touch should remain tap/scroll behavior. + useSensor(MouseSensor, { activationConstraint: { distance: 8 }, }), ); diff --git a/ui/src/components/SwipeToArchive.test.tsx b/ui/src/components/SwipeToArchive.test.tsx new file mode 100644 index 00000000..8a3c07f3 --- /dev/null +++ b/ui/src/components/SwipeToArchive.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SwipeToArchive } from "./SwipeToArchive"; + +// Tell React this environment uses act() for event flushing. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function dispatchTouchEvent( + node: Element, + type: "touchstart" | "touchmove" | "touchend", + coords: { x: number; y: number }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + const touchPoint = { clientX: coords.x, clientY: coords.y }; + + Object.defineProperty(event, "touches", { + configurable: true, + value: type === "touchend" ? [] : [touchPoint], + }); + Object.defineProperty(event, "changedTouches", { + configurable: true, + value: [touchPoint], + }); + + node.dispatchEvent(event); +} + +describe("SwipeToArchive", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + container.remove(); + }); + + it("suppresses descendant clicks after a horizontal swipe and archives the row", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const wrapper = container.firstElementChild as HTMLDivElement; + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 }); + Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 }); + + act(() => { + dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 }); + }); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(140); + }); + + expect(onArchive).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it("does not suppress a normal tap click", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onArchive).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + + it("renders the selected inbox treatment on the swipe surface", () => { + const root = createRoot(container); + + act(() => { + root.render( + {}} selected> + + , + ); + }); + + const surface = container.querySelector("[data-inbox-row-surface]") as HTMLDivElement | null; + expect(surface).not.toBeNull(); + expect(surface?.className).toContain("bg-zinc-100"); + expect(surface?.className).toContain("dark:bg-zinc-800"); + expect(surface?.className).not.toContain("bg-card"); + expect(surface?.style.backgroundColor).toBe(""); + expect(surface?.style.boxShadow).toBe(""); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/SwipeToArchive.tsx b/ui/src/components/SwipeToArchive.tsx new file mode 100644 index 00000000..639179c3 --- /dev/null +++ b/ui/src/components/SwipeToArchive.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { Archive } from "lucide-react"; +import { cn } from "../lib/utils"; + +interface SwipeToArchiveProps { + children: ReactNode; + onArchive: () => void; + disabled?: boolean; + selected?: boolean; + className?: string; +} + +const COMMIT_THRESHOLD = 0.32; +const MAX_SWIPE = 0.88; +const COMMIT_DELAY_MS = 140; + +export function SwipeToArchive({ + children, + onArchive, + disabled = false, + selected = false, + className, +}: SwipeToArchiveProps) { + const containerRef = useRef(null); + const startPointRef = useRef<{ x: number; y: number } | null>(null); + const widthRef = useRef(0); + const timeoutRef = useRef(null); + const suppressClickRef = useRef(false); + const [offsetX, setOffsetX] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [isCollapsing, setIsCollapsing] = useState(false); + const [lockedHeight, setLockedHeight] = useState(null); + + useEffect(() => { + return () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }; + }, []); + + const reset = () => { + startPointRef.current = null; + setIsDragging(false); + setOffsetX(0); + }; + + const commitArchive = () => { + const node = containerRef.current; + if (!node) { + onArchive(); + return; + } + setIsDragging(false); + setLockedHeight(node.offsetHeight); + setOffsetX(-Math.max(widthRef.current, node.offsetWidth)); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + setIsCollapsing(true); + }); + }); + timeoutRef.current = window.setTimeout(() => { + onArchive(); + }, COMMIT_DELAY_MS); + }; + + const handleTouchStart = (event: React.TouchEvent) => { + if (disabled || event.touches.length !== 1) return; + const touch = event.touches[0]; + const node = containerRef.current; + widthRef.current = node?.offsetWidth ?? 0; + setLockedHeight(node?.offsetHeight ?? null); + setIsCollapsing(false); + suppressClickRef.current = false; + startPointRef.current = { x: touch.clientX, y: touch.clientY }; + }; + + const handleTouchMove = (event: React.TouchEvent) => { + if (disabled || isCollapsing) return; + const startPoint = startPointRef.current; + if (!startPoint || event.touches.length !== 1) return; + + const touch = event.touches[0]; + const deltaX = touch.clientX - startPoint.x; + const deltaY = touch.clientY - startPoint.y; + + if (!isDragging) { + if (Math.abs(deltaX) < 6) return; + if (Math.abs(deltaY) > Math.abs(deltaX)) { + startPointRef.current = null; + return; + } + suppressClickRef.current = true; + } + + if (deltaX >= 0) { + event.preventDefault(); + setIsDragging(true); + setOffsetX(0); + return; + } + + const maxSwipe = widthRef.current > 0 ? widthRef.current * MAX_SWIPE : Number.POSITIVE_INFINITY; + event.preventDefault(); + setIsDragging(true); + setOffsetX(Math.max(deltaX, -maxSwipe)); + }; + + const handleTouchEnd = () => { + if (disabled || isCollapsing) return; + const shouldCommit = + widthRef.current > 0 && Math.abs(offsetX) >= widthRef.current * COMMIT_THRESHOLD; + if (shouldCommit) { + commitArchive(); + return; + } + reset(); + }; + + const archiveReveal = widthRef.current > 0 ? Math.min(Math.abs(offsetX) / widthRef.current, 1) : 0; + + return ( +
{ + if (!suppressClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + suppressClickRef.current = false; + }} + > + +
+ {children} +
+
+ ); +} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 3c0fd25b..481aa4b0 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -30,7 +30,7 @@ export const help: Record = { model: "Override the default model used by the adapter.", thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.", chrome: "Enable Claude's Chrome integration by passing --chrome.", - dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", + dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", search: "Enable Codex web search capability during runs.", workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.", @@ -64,6 +64,7 @@ export const adapterLabels: Record = { opencode_local: "OpenCode (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", + hermes_local: "Hermes Agent", process: "Process", http: "HTTP", }; @@ -104,11 +105,13 @@ export function ToggleField({ hint, checked, onChange, + toggleTestId, }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; + toggleTestId?: string; }) { return (
@@ -118,6 +121,8 @@ export function ToggleField({
+
+ {open && ( +
+ {block.items.map((item, index) => ( +
+
+ + + + + {humanizeLabel(item.name)} + + + {item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"} + +
+
+
+
Input
+
+                    {formatToolPayload(item.input) || ""}
+                  
+
+ {item.result && ( +
+
Result
+
+                      {formatToolPayload(item.result)}
+                    
+
+ )} +
+
+ ))} +
+ )} +
+ ); +} + function TranscriptActivityRow({ block, density, @@ -883,6 +1084,43 @@ function TranscriptEventRow({ ); } +function TranscriptStderrGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + return ( +
+
setOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > + + {block.lines.length} log {block.lines.length === 1 ? "line" : "lines"} + + {open ? : } +
+ {open && ( +
+          {block.lines.map((line, i) => (
+            
+              {i > 0 ? "\n" : ""}
+              {line.text}
+            
+          ))}
+        
+ )} +
+ ); +} + function TranscriptStdoutRow({ block, density, @@ -1003,6 +1241,8 @@ export function RunTranscriptView({ )} {block.type === "tool" && } {block.type === "command_group" && } + {block.type === "tool_group" && } + {block.type === "stderr_group" && } {block.type === "stdout" && ( )} diff --git a/ui/src/context/LiveUpdatesProvider.test.ts b/ui/src/context/LiveUpdatesProvider.test.ts index 620cf6ca..8aaa7581 100644 --- a/ui/src/context/LiveUpdatesProvider.test.ts +++ b/ui/src/context/LiveUpdatesProvider.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider"; import { queryKeys } from "../lib/queryKeys"; @@ -24,6 +24,9 @@ describe("LiveUpdatesProvider issue invalidation", () => { }, ); + expect(invalidations).toContainEqual({ + queryKey: queryKeys.issues.listMineByMe("company-1"), + }); expect(invalidations).toContainEqual({ queryKey: queryKeys.issues.listTouchedByMe("company-1"), }); @@ -114,3 +117,129 @@ describe("LiveUpdatesProvider visible issue toast suppression", () => { ).toBe(true); }); }); + +describe("LiveUpdatesProvider run lifecycle toasts", () => { + it("does not build start or success toasts for agent runs", () => { + const queryClient = { + getQueryData: () => [], + }; + + expect( + __liveUpdatesTestUtils.buildAgentStatusToast( + { + agentId: "agent-1", + status: "running", + }, + () => "CodexCoder", + queryClient as never, + "company-1", + ), + ).toBeNull(); + + expect( + __liveUpdatesTestUtils.buildRunStatusToast( + { + runId: "run-1", + agentId: "agent-1", + status: "succeeded", + }, + () => "CodexCoder", + ), + ).toBeNull(); + }); + + it("still builds failure toasts for agent errors and failed runs", () => { + const queryClient = { + getQueryData: () => [ + { + id: "agent-1", + title: "Software Engineer", + }, + ], + }; + + expect( + __liveUpdatesTestUtils.buildAgentStatusToast( + { + agentId: "agent-1", + status: "error", + }, + () => "CodexCoder", + queryClient as never, + "company-1", + ), + ).toMatchObject({ + title: "CodexCoder errored", + body: "Software Engineer", + tone: "error", + }); + + expect( + __liveUpdatesTestUtils.buildRunStatusToast( + { + runId: "run-1", + agentId: "agent-1", + status: "failed", + error: "boom", + }, + () => "CodexCoder", + ), + ).toMatchObject({ + title: "CodexCoder run failed", + body: "boom", + tone: "error", + }); + }); +}); + +describe("LiveUpdatesProvider socket helpers", () => { + it("waits for the selected company object to catch up before connecting", () => { + expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", null)).toBeNull(); + expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-2")).toBeNull(); + expect(__liveUpdatesTestUtils.resolveLiveCompanyId("company-1", "company-1")).toBe("company-1"); + }); + + it("defers close until onopen for sockets that are still connecting", () => { + const socket = { + readyState: 0, + onopen: (() => undefined) as (() => void) | null, + onmessage: (() => undefined) as (() => void) | null, + onerror: (() => undefined) as (() => void) | null, + onclose: (() => undefined) as (() => void) | null, + close: vi.fn(), + }; + + __liveUpdatesTestUtils.closeSocketQuietly(socket as never, "provider_unmount"); + + expect(socket.close).not.toHaveBeenCalled(); + expect(socket.onmessage).toBeNull(); + expect(socket.onclose).toBeNull(); + expect(socket.onopen).toBeTypeOf("function"); + expect(socket.onerror).toBeTypeOf("function"); + + socket.onopen?.(); + + expect(socket.close).toHaveBeenCalledWith(1000, "provider_unmount"); + expect(socket.onopen).toBeNull(); + expect(socket.onerror).toBeNull(); + }); + + it("closes open sockets immediately without leaving handlers behind", () => { + const socket = { + readyState: 1, + onopen: (() => undefined) as (() => void) | null, + onmessage: (() => undefined) as (() => void) | null, + onerror: (() => undefined) as (() => void) | null, + onclose: (() => undefined) as (() => void) | null, + close: vi.fn(), + }; + + __liveUpdatesTestUtils.closeSocketQuietly(socket as never, "stale_connection"); + + expect(socket.close).toHaveBeenCalledWith(1000, "stale_connection"); + expect(socket.onopen).toBeNull(); + expect(socket.onmessage).toBeNull(); + expect(socket.onerror).toBeNull(); + expect(socket.onclose).toBeNull(); + }); +}); diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 86746dcc..71e6fbc8 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -14,6 +14,17 @@ import { useLocation } from "../lib/router"; const TOAST_COOLDOWN_WINDOW_MS = 10_000; const TOAST_COOLDOWN_MAX = 3; const RECONNECT_SUPPRESS_MS = 2000; +const SOCKET_CONNECTING = 0; +const SOCKET_OPEN = 1; + +type LiveUpdatesSocketLike = { + readyState: number; + onopen: ((this: WebSocket, ev: Event) => unknown) | null; + onmessage: ((this: WebSocket, ev: MessageEvent) => unknown) | null; + onerror: ((this: WebSocket, ev: Event) => unknown) | null; + onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null; + close: (code?: number, reason?: string) => void; +}; function readString(value: unknown): string | null { return typeof value === "string" && value.length > 0 ? value : null; @@ -244,8 +255,8 @@ function shouldSuppressAgentStatusToastForVisibleIssue( } const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]); -const AGENT_TOAST_STATUSES = new Set(["running", "error"]); -const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]); +const AGENT_TOAST_STATUSES = new Set(["error"]); +const RUN_TOAST_STATUSES = new Set(["failed", "timed_out", "cancelled"]); function describeIssueUpdate(details: Record | null): string | null { if (!details) return null; @@ -374,7 +385,7 @@ function buildJoinRequestToast( title: `${label} wants to join`, body: "A new join request is waiting for approval.", tone: "info", - action: { label: "View inbox", href: "/inbox/unread" }, + action: { label: "View inbox", href: "/inbox/mine" }, dedupeKey: `join-request:${entityId}`, }; } @@ -416,7 +427,7 @@ function buildRunStatusToast( const runId = readString(payload.runId); const agentId = readString(payload.agentId); const status = readString(payload.status); - if (!runId || !agentId || !status || !TERMINAL_RUN_STATUSES.has(status)) return null; + if (!runId || !agentId || !status || !RUN_TOAST_STATUSES.has(status)) return null; const error = readString(payload.error); const triggerDetail = readString(payload.triggerDetail); @@ -479,6 +490,7 @@ function invalidateActivityQueries( if (entityType === "issue") { queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.issues.listMineByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) }); queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) }); if (entityId) { @@ -651,33 +663,90 @@ function handleLiveEvent( } } +function resolveLiveCompanyId( + selectedCompanyId: string | null, + selectedCompanyLiveId: string | null, +): string | null { + return selectedCompanyId && selectedCompanyId === selectedCompanyLiveId + ? selectedCompanyId + : null; +} + +function resetSocketHandlers(target: LiveUpdatesSocketLike) { + target.onopen = null; + target.onmessage = null; + target.onerror = null; + target.onclose = null; +} + +function closeSocketQuietly(target: LiveUpdatesSocketLike | null, reason: string) { + if (!target) return; + + if (target.readyState === SOCKET_CONNECTING) { + // Let the handshake complete and then close. Calling close() while the + // socket is still CONNECTING is what triggers the noisy browser error. + target.onopen = () => { + resetSocketHandlers(target); + target.close(1000, reason); + }; + target.onmessage = null; + target.onerror = () => undefined; + target.onclose = null; + return; + } + + resetSocketHandlers(target); + + if (target.readyState === SOCKET_OPEN) { + target.close(1000, reason); + } +} + export const __liveUpdatesTestUtils = { + buildAgentStatusToast, + buildRunStatusToast, + closeSocketQuietly, invalidateActivityQueries, + resolveLiveCompanyId, shouldSuppressActivityToastForVisibleIssue, shouldSuppressRunStatusToastForVisibleIssue, shouldSuppressAgentStatusToastForVisibleIssue, }; export function LiveUpdatesProvider({ children }: { children: ReactNode }) { - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const location = useLocation(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); const pathnameRef = useRef(location.pathname); - const { data: session } = useQuery({ + const { data: session, status: sessionStatus } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out"; + const liveCompanyId = resolveLiveCompanyId(selectedCompanyId, selectedCompany?.id ?? null); + const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null; + const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({ + userId: currentUserId, + agentId: null, + }); useEffect(() => { pathnameRef.current = location.pathname; }, [location.pathname]); useEffect(() => { - if (!selectedCompanyId) return; + currentActorRef.current = { + userId: currentUserId, + agentId: null, + }; + }, [currentUserId]); + + useEffect(() => { + if (!canConnectSocket || !liveCompanyId) return; let closed = false; let reconnectAttempt = 0; @@ -704,55 +773,63 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const connect = () => { if (closed) return; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`; - socket = new WebSocket(url); + const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`; + const nextSocket = new WebSocket(url); + socket = nextSocket; - socket.onopen = () => { + nextSocket.onopen = () => { + if (closed || socket !== nextSocket) { + closeSocketQuietly(nextSocket, "stale_connection"); + return; + } if (reconnectAttempt > 0) { gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS; } reconnectAttempt = 0; }; - socket.onmessage = (message) => { + nextSocket.onmessage = (message) => { const raw = typeof message.data === "string" ? message.data : ""; if (!raw) return; try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, { - userId: currentUserId, - agentId: null, + handleLiveEvent(queryClient, liveCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, { + userId: currentActorRef.current.userId, + agentId: currentActorRef.current.agentId, }); } catch { // Ignore non-JSON payloads. } }; - socket.onerror = () => { - socket?.close(); + nextSocket.onerror = () => { + // Wait for onclose to drive the reconnect. Self-closing here is what + // produces the "closed before connection established" browser noise. }; - socket.onclose = () => { + nextSocket.onclose = () => { + if (socket !== nextSocket) return; + socket = null; if (closed) return; scheduleReconnect(); }; }; - connect(); + // Delay initial connect slightly so React StrictMode's double-invoke + // cleanup fires before the WebSocket is created, avoiding the + // "WebSocket closed before connection established" dev-mode error. + const connectTimer = window.setTimeout(connect, 0); return () => { closed = true; + window.clearTimeout(connectTimer); clearReconnect(); - if (socket) { - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "provider_unmount"); - } + const activeSocket = socket; + socket = null; + closeSocketQuietly(activeSocket, "provider_unmount"); }; - }, [queryClient, selectedCompanyId, pushToast, currentUserId]); + }, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]); return <>{children}; } diff --git a/ui/src/hooks/useInboxBadge.ts b/ui/src/hooks/useInboxBadge.ts index fff0ff13..6b7daa2b 100644 --- a/ui/src/hooks/useInboxBadge.ts +++ b/ui/src/hooks/useInboxBadge.ts @@ -12,7 +12,9 @@ import { getRecentTouchedIssues, loadDismissedInboxItems, saveDismissedInboxItems, - getUnreadTouchedIssues, + loadReadInboxItems, + saveReadInboxItems, + READ_ITEMS_KEY, } from "../lib/inbox"; const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done"; @@ -41,6 +43,39 @@ export function useDismissedInboxItems() { return { dismissed, dismiss }; } +export function useReadInboxItems() { + const [readItems, setReadItems] = useState>(loadReadInboxItems); + + useEffect(() => { + const handleStorage = (event: StorageEvent) => { + if (event.key !== READ_ITEMS_KEY) return; + setReadItems(loadReadInboxItems()); + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + + const markRead = (id: string) => { + setReadItems((prev) => { + const next = new Set(prev); + next.add(id); + saveReadInboxItems(next); + return next; + }); + }; + + const markUnread = (id: string) => { + setReadItems((prev) => { + const next = new Set(prev); + next.delete(id); + saveReadInboxItems(next); + return next; + }); + }; + + return { readItems, markRead, markUnread }; +} + export function useInboxBadge(companyId: string | null | undefined) { const { dismissed } = useDismissedInboxItems(); @@ -72,20 +107,18 @@ export function useInboxBadge(companyId: string | null | undefined) { enabled: !!companyId, }); - const { data: touchedIssues = [] } = useQuery({ - queryKey: queryKeys.issues.listTouchedByMe(companyId!), + const { data: mineIssuesRaw = [] } = useQuery({ + queryKey: queryKeys.issues.listMineByMe(companyId!), queryFn: () => issuesApi.list(companyId!, { touchedByUserId: "me", + inboxArchivedByUserId: "me", status: INBOX_ISSUE_STATUSES, }), enabled: !!companyId, }); - const unreadIssues = useMemo( - () => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)), - [touchedIssues], - ); + const mineIssues = useMemo(() => getRecentTouchedIssues(mineIssuesRaw), [mineIssuesRaw]); const { data: heartbeatRuns = [] } = useQuery({ queryKey: queryKeys.heartbeats(companyId!), @@ -100,9 +133,9 @@ export function useInboxBadge(companyId: string | null | undefined) { joinRequests, dashboard, heartbeatRuns, - unreadIssues, + mineIssues, dismissed, }), - [approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed], + [approvals, joinRequests, dashboard, heartbeatRuns, mineIssues, dismissed], ); } diff --git a/ui/src/index.css b/ui/src/index.css index b0f839ec..c220e8cd 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -343,6 +343,17 @@ margin-top: 1.1em; } +.paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); + text-decoration: underline; + text-underline-offset: 0.15em; + cursor: pointer; +} + +.dark .paperclip-mdxeditor-content a:not(.paperclip-mention-chip):not(.paperclip-project-mention-chip) { + color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%); +} + .paperclip-mdxeditor-content a.paperclip-mention-chip, .paperclip-mdxeditor-content a.paperclip-project-mention-chip { display: inline-flex; @@ -661,12 +672,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { .paperclip-markdown a { color: color-mix(in oklab, var(--foreground) 76%, #0969da 24%); - text-decoration: none; -} - -.paperclip-markdown a:hover { text-decoration: underline; text-underline-offset: 0.15em; + cursor: pointer; +} + +.paperclip-markdown a.paperclip-mention-chip { + text-decoration: none; } .dark .paperclip-markdown a { diff --git a/ui/src/lib/comment-submit-draft.test.ts b/ui/src/lib/comment-submit-draft.test.ts new file mode 100644 index 00000000..395885f6 --- /dev/null +++ b/ui/src/lib/comment-submit-draft.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { restoreSubmittedCommentDraft } from "./comment-submit-draft"; + +describe("restoreSubmittedCommentDraft", () => { + it("restores the submitted body when the editor is still empty after a failed request", () => { + expect( + restoreSubmittedCommentDraft({ + currentBody: "", + submittedBody: "Retry me", + }), + ).toBe("Retry me"); + }); + + it("treats whitespace-only input as empty when restoring a failed draft", () => { + expect( + restoreSubmittedCommentDraft({ + currentBody: " ", + submittedBody: "Retry me", + }), + ).toBe("Retry me"); + }); + + it("preserves newer input when the user has already typed again", () => { + expect( + restoreSubmittedCommentDraft({ + currentBody: "new draft", + submittedBody: "Retry me", + }), + ).toBe("new draft"); + }); +}); diff --git a/ui/src/lib/comment-submit-draft.ts b/ui/src/lib/comment-submit-draft.ts new file mode 100644 index 00000000..5d9b56cc --- /dev/null +++ b/ui/src/lib/comment-submit-draft.ts @@ -0,0 +1,6 @@ +export function restoreSubmittedCommentDraft(params: { + currentBody: string; + submittedBody: string; +}) { + return params.currentBody.trim() ? params.currentBody : params.submittedBody; +} diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts new file mode 100644 index 00000000..d6dc2668 --- /dev/null +++ b/ui/src/lib/company-routes.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + applyCompanyPrefix, + extractCompanyPrefixFromPath, + isBoardPathWithoutPrefix, + toCompanyRelativePath, +} from "./company-routes"; + +describe("company routes", () => { + it("treats execution workspace paths as board routes that need a company prefix", () => { + expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true); + expect(extractCompanyPrefixFromPath("/execution-workspaces/workspace-123")).toBeNull(); + expect(applyCompanyPrefix("/execution-workspaces/workspace-123", "PAP")).toBe( + "/PAP/execution-workspaces/workspace-123", + ); + }); + + it("normalizes prefixed execution workspace paths back to company-relative paths", () => { + expect(toCompanyRelativePath("/PAP/execution-workspaces/workspace-123")).toBe( + "/execution-workspaces/workspace-123", + ); + }); +}); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index b8d51fd5..48d71ef2 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -6,6 +6,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "org", "agents", "projects", + "execution-workspaces", "issues", "routines", "goals", diff --git a/ui/src/lib/inbox.test.ts b/ui/src/lib/inbox.test.ts index fe69813a..a67a7451 100644 --- a/ui/src/lib/inbox.test.ts +++ b/ui/src/lib/inbox.test.ts @@ -6,10 +6,13 @@ import { computeInboxBadgeData, getApprovalsForTab, getInboxWorkItems, + getInboxKeyboardSelectionIndex, getRecentTouchedIssues, getUnreadTouchedIssues, + isMineInboxTab, loadLastInboxTab, RECENT_ISSUES_LIMIT, + resolveInboxSelectionIndex, saveLastInboxTab, shouldShowInboxSection, } from "./inbox"; @@ -210,7 +213,7 @@ describe("inbox helpers", () => { makeRun("run-latest", "timed_out", "2026-03-11T01:00:00.000Z"), makeRun("run-other-agent", "failed", "2026-03-11T02:00:00.000Z", "agent-2"), ], - unreadIssues: [makeIssue("1", true)], + mineIssues: [makeIssue("1", true)], dismissed: new Set(), }); @@ -219,7 +222,7 @@ describe("inbox helpers", () => { approvals: 1, failedRuns: 2, joinRequests: 1, - unreadTouchedIssues: 1, + mineIssues: 1, alerts: 1, }); }); @@ -230,7 +233,7 @@ describe("inbox helpers", () => { joinRequests: [], dashboard, heartbeatRuns: [makeRun("run-1", "failed", "2026-03-11T00:00:00.000Z")], - unreadIssues: [], + mineIssues: [], dismissed: new Set(["run:run-1", "alert:budget", "alert:agent-errors"]), }); @@ -239,7 +242,7 @@ describe("inbox helpers", () => { approvals: 0, failedRuns: 0, joinRequests: 0, - unreadTouchedIssues: 0, + mineIssues: 0, alerts: 0, }); }); @@ -262,6 +265,11 @@ describe("inbox helpers", () => { ), ]; + expect(getApprovalsForTab(approvals, "mine", "all").map((approval) => approval.id)).toEqual([ + "approval-revision", + "approval-approved", + "approval-pending", + ]); expect(getApprovalsForTab(approvals, "recent", "all").map((approval) => approval.id)).toEqual([ "approval-revision", "approval-approved", @@ -338,10 +346,21 @@ describe("inbox helpers", () => { }); it("can include sections on recent without forcing them to be unread", () => { + expect( + shouldShowInboxSection({ + tab: "mine", + hasItems: true, + showOnMine: true, + showOnRecent: false, + showOnUnread: false, + showOnAll: false, + }), + ).toBe(true); expect( shouldShowInboxSection({ tab: "recent", hasItems: true, + showOnMine: false, showOnRecent: true, showOnUnread: false, showOnAll: false, @@ -351,6 +370,7 @@ describe("inbox helpers", () => { shouldShowInboxSection({ tab: "unread", hasItems: true, + showOnMine: true, showOnRecent: true, showOnUnread: false, showOnAll: false, @@ -371,16 +391,36 @@ describe("inbox helpers", () => { expect(getUnreadTouchedIssues(recentIssues).map((issue) => issue.id)).toEqual(["1", "2", "3"]); }); - it("defaults the remembered inbox tab to recent and persists all", () => { + it("defaults the remembered inbox tab to mine and persists all", () => { localStorage.clear(); - expect(loadLastInboxTab()).toBe("recent"); + expect(loadLastInboxTab()).toBe("mine"); saveLastInboxTab("all"); expect(loadLastInboxTab()).toBe("all"); }); - it("maps legacy new-tab storage to recent", () => { + it("maps legacy new-tab storage to mine", () => { localStorage.setItem("paperclip:inbox:last-tab", "new"); - expect(loadLastInboxTab()).toBe("recent"); + expect(loadLastInboxTab()).toBe("mine"); + }); + + it("enables swipe archive only on the mine tab", () => { + expect(isMineInboxTab("mine")).toBe(true); + expect(isMineInboxTab("recent")).toBe(false); + expect(isMineInboxTab("unread")).toBe(false); + expect(isMineInboxTab("all")).toBe(false); + }); + + it("anchors Mine selection to the first available inbox row", () => { + expect(resolveInboxSelectionIndex(-1, 3)).toBe(-1); + expect(resolveInboxSelectionIndex(5, 3)).toBe(2); + expect(resolveInboxSelectionIndex(1, 0)).toBe(-1); + }); + + it("selects the first row only after keyboard navigation starts", () => { + expect(getInboxKeyboardSelectionIndex(-1, 3, "next")).toBe(0); + expect(getInboxKeyboardSelectionIndex(-1, 3, "previous")).toBe(0); + expect(getInboxKeyboardSelectionIndex(0, 3, "next")).toBe(1); + expect(getInboxKeyboardSelectionIndex(0, 3, "previous")).toBe(0); }); }); diff --git a/ui/src/lib/inbox.ts b/ui/src/lib/inbox.ts index f2719e17..e86a02ee 100644 --- a/ui/src/lib/inbox.ts +++ b/ui/src/lib/inbox.ts @@ -10,8 +10,9 @@ export const RECENT_ISSUES_LIMIT = 100; export const FAILED_RUN_STATUSES = new Set(["failed", "timed_out"]); export const ACTIONABLE_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]); export const DISMISSED_KEY = "paperclip:inbox:dismissed"; +export const READ_ITEMS_KEY = "paperclip:inbox:read-items"; export const INBOX_LAST_TAB_KEY = "paperclip:inbox:last-tab"; -export type InboxTab = "recent" | "unread" | "all"; +export type InboxTab = "mine" | "recent" | "unread" | "all"; export type InboxApprovalFilter = "all" | "actionable" | "resolved"; export type InboxWorkItem = | { @@ -40,7 +41,7 @@ export interface InboxBadgeData { approvals: number; failedRuns: number; joinRequests: number; - unreadTouchedIssues: number; + mineIssues: number; alerts: number; } @@ -61,14 +62,31 @@ export function saveDismissedInboxItems(ids: Set) { } } +export function loadReadInboxItems(): Set { + try { + const raw = localStorage.getItem(READ_ITEMS_KEY); + return raw ? new Set(JSON.parse(raw)) : new Set(); + } catch { + return new Set(); + } +} + +export function saveReadInboxItems(ids: Set) { + try { + localStorage.setItem(READ_ITEMS_KEY, JSON.stringify([...ids])); + } catch { + // Ignore localStorage failures. + } +} + export function loadLastInboxTab(): InboxTab { try { const raw = localStorage.getItem(INBOX_LAST_TAB_KEY); - if (raw === "all" || raw === "unread" || raw === "recent") return raw; - if (raw === "new") return "recent"; - return "recent"; + if (raw === "all" || raw === "unread" || raw === "recent" || raw === "mine") return raw; + if (raw === "new") return "mine"; + return "mine"; } catch { - return "recent"; + return "mine"; } } @@ -80,6 +98,31 @@ export function saveLastInboxTab(tab: InboxTab) { } } +export function isMineInboxTab(tab: InboxTab): boolean { + return tab === "mine"; +} + +export function resolveInboxSelectionIndex( + previousIndex: number, + itemCount: number, +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return -1; + return Math.min(previousIndex, itemCount - 1); +} + +export function getInboxKeyboardSelectionIndex( + previousIndex: number, + itemCount: number, + direction: "next" | "previous", +): number { + if (itemCount === 0) return -1; + if (previousIndex < 0) return 0; + return direction === "next" + ? Math.min(previousIndex + 1, itemCount - 1) + : Math.max(previousIndex - 1, 0); +} + export function getLatestFailedRunsByAgent(runs: HeartbeatRun[]): HeartbeatRun[] { const sorted = [...runs].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), @@ -135,7 +178,7 @@ export function getApprovalsForTab( (a, b) => normalizeTimestamp(b.updatedAt) - normalizeTimestamp(a.updatedAt), ); - if (tab === "recent") return sortedApprovals; + if (tab === "mine" || tab === "recent") return sortedApprovals; if (tab === "unread") { return sortedApprovals.filter((approval) => ACTIONABLE_APPROVAL_STATUSES.has(approval.status)); } @@ -203,17 +246,20 @@ export function getInboxWorkItems({ export function shouldShowInboxSection({ tab, hasItems, + showOnMine, showOnRecent, showOnUnread, showOnAll, }: { tab: InboxTab; hasItems: boolean; + showOnMine: boolean; showOnRecent: boolean; showOnUnread: boolean; showOnAll: boolean; }): boolean { if (!hasItems) return false; + if (tab === "mine") return showOnMine; if (tab === "recent") return showOnRecent; if (tab === "unread") return showOnUnread; return showOnAll; @@ -224,23 +270,28 @@ export function computeInboxBadgeData({ joinRequests, dashboard, heartbeatRuns, - unreadIssues, + mineIssues, dismissed, }: { approvals: Approval[]; joinRequests: JoinRequest[]; dashboard: DashboardSummary | undefined; heartbeatRuns: HeartbeatRun[]; - unreadIssues: Issue[]; + mineIssues: Issue[]; dismissed: Set; }): InboxBadgeData { - const actionableApprovals = approvals.filter((approval) => - ACTIONABLE_APPROVAL_STATUSES.has(approval.status), + const actionableApprovals = approvals.filter( + (approval) => + ACTIONABLE_APPROVAL_STATUSES.has(approval.status) && + !dismissed.has(`approval:${approval.id}`), ).length; const failedRuns = getLatestFailedRunsByAgent(heartbeatRuns).filter( (run) => !dismissed.has(`run:${run.id}`), ).length; - const unreadTouchedIssues = unreadIssues.length; + const visibleJoinRequests = joinRequests.filter( + (jr) => !dismissed.has(`join:${jr.id}`), + ).length; + const visibleMineIssues = mineIssues.length; const agentErrorCount = dashboard?.agents.error ?? 0; const monthBudgetCents = dashboard?.costs.monthBudgetCents ?? 0; const monthUtilizationPercent = dashboard?.costs.monthUtilizationPercent ?? 0; @@ -255,11 +306,11 @@ export function computeInboxBadgeData({ const alerts = Number(showAggregateAgentError) + Number(showBudgetAlert); return { - inbox: actionableApprovals + joinRequests.length + failedRuns + unreadTouchedIssues + alerts, + inbox: actionableApprovals + visibleJoinRequests + failedRuns + visibleMineIssues + alerts, approvals: actionableApprovals, failedRuns, - joinRequests: joinRequests.length, - unreadTouchedIssues, + joinRequests: visibleJoinRequests, + mineIssues: visibleMineIssues, alerts, }; } diff --git a/ui/src/lib/issueDetailBreadcrumb.test.ts b/ui/src/lib/issueDetailBreadcrumb.test.ts new file mode 100644 index 00000000..dcb18479 --- /dev/null +++ b/ui/src/lib/issueDetailBreadcrumb.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + createIssueDetailLocationState, + createIssueDetailPath, + readIssueDetailBreadcrumb, +} from "./issueDetailBreadcrumb"; + +describe("issueDetailBreadcrumb", () => { + it("prefers the full breadcrumb from route state", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(readIssueDetailBreadcrumb(state, "?from=issues")).toEqual({ + label: "Inbox", + href: "/inbox/mine", + }); + }); + + it("falls back to the source query param when route state is unavailable", () => { + expect(readIssueDetailBreadcrumb(null, "?from=inbox")).toEqual({ + label: "Inbox", + href: "/inbox", + }); + }); + + it("adds the source query param when building an issue detail path", () => { + const state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox"); + + expect(createIssueDetailPath("PAP-465", state)).toBe("/issues/PAP-465?from=inbox"); + }); + + it("reuses the current source query param when state has been dropped", () => { + expect(createIssueDetailPath("PAP-465", null, "?from=issues")).toBe("/issues/PAP-465?from=issues"); + }); +}); diff --git a/ui/src/lib/issueDetailBreadcrumb.ts b/ui/src/lib/issueDetailBreadcrumb.ts index ba330eb3..1f940ef8 100644 --- a/ui/src/lib/issueDetailBreadcrumb.ts +++ b/ui/src/lib/issueDetailBreadcrumb.ts @@ -1,3 +1,5 @@ +type IssueDetailSource = "issues" | "inbox"; + type IssueDetailBreadcrumb = { label: string; href: string; @@ -5,20 +7,64 @@ type IssueDetailBreadcrumb = { type IssueDetailLocationState = { issueDetailBreadcrumb?: IssueDetailBreadcrumb; + issueDetailSource?: IssueDetailSource; }; +const ISSUE_DETAIL_SOURCE_QUERY_PARAM = "from"; + function isIssueDetailBreadcrumb(value: unknown): value is IssueDetailBreadcrumb { if (typeof value !== "object" || value === null) return false; const candidate = value as Partial; return typeof candidate.label === "string" && typeof candidate.href === "string"; } -export function createIssueDetailLocationState(label: string, href: string): IssueDetailLocationState { - return { issueDetailBreadcrumb: { label, href } }; +function isIssueDetailSource(value: unknown): value is IssueDetailSource { + return value === "issues" || value === "inbox"; } -export function readIssueDetailBreadcrumb(state: unknown): IssueDetailBreadcrumb | null { +function readIssueDetailSource(state: unknown): IssueDetailSource | null { if (typeof state !== "object" || state === null) return null; - const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; - return isIssueDetailBreadcrumb(candidate) ? candidate : null; + const source = (state as IssueDetailLocationState).issueDetailSource; + return isIssueDetailSource(source) ? source : null; +} + +function readIssueDetailSourceFromSearch(search?: string): IssueDetailSource | null { + if (!search) return null; + const params = new URLSearchParams(search); + const source = params.get(ISSUE_DETAIL_SOURCE_QUERY_PARAM); + return isIssueDetailSource(source) ? source : null; +} + +function breadcrumbForSource(source: IssueDetailSource): IssueDetailBreadcrumb { + if (source === "inbox") return { label: "Inbox", href: "/inbox" }; + return { label: "Issues", href: "/issues" }; +} + +export function createIssueDetailLocationState( + label: string, + href: string, + source?: IssueDetailSource, +): IssueDetailLocationState { + return { + issueDetailBreadcrumb: { label, href }, + issueDetailSource: source, + }; +} + +export function createIssueDetailPath(issuePathId: string, state?: unknown, search?: string): string { + const source = readIssueDetailSource(state) ?? readIssueDetailSourceFromSearch(search); + if (!source) return `/issues/${issuePathId}`; + const params = new URLSearchParams(); + params.set(ISSUE_DETAIL_SOURCE_QUERY_PARAM, source); + return `/issues/${issuePathId}?${params.toString()}`; +} + +export function readIssueDetailBreadcrumb(state: unknown, search?: string): IssueDetailBreadcrumb | null { + if (typeof state === "object" && state !== null) { + const candidate = (state as IssueDetailLocationState).issueDetailBreadcrumb; + if (isIssueDetailBreadcrumb(candidate)) return candidate; + } + + const source = readIssueDetailSourceFromSearch(search); + return source ? breadcrumbForSource(source) : null; } diff --git a/ui/src/lib/optimistic-issue-comments.test.ts b/ui/src/lib/optimistic-issue-comments.test.ts new file mode 100644 index 00000000..bb4ae9ae --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + applyOptimisticIssueCommentUpdate, + createOptimisticIssueComment, + isQueuedIssueComment, + mergeIssueComments, + upsertIssueComment, +} from "./optimistic-issue-comments"; + +describe("optimistic issue comments", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("creates a pending optimistic comment for the current user", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toMatch(/^optimistic-/); + expect(comment.clientId).toBe(comment.id); + expect(comment.clientStatus).toBe("pending"); + expect(comment.authorUserId).toBe("board-1"); + expect(comment.authorAgentId).toBeNull(); + }); + + it("falls back when crypto.randomUUID is unavailable", () => { + vi.stubGlobal("crypto", {}); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_746_000_000_000); + const mathSpy = vi.spyOn(Math, "random").mockReturnValue(0.123456789); + + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Working on it", + authorUserId: "board-1", + }); + + expect(comment.id).toBe("optimistic-1746000000000-4fzzzxjy"); + expect(comment.clientId).toBe(comment.id); + + nowSpy.mockRestore(); + mathSpy.mockRestore(); + }); + + it("supports queued optimistic comments for active-run follow-ups", () => { + const comment = createOptimisticIssueComment({ + companyId: "company-1", + issueId: "issue-1", + body: "Queue this", + authorUserId: "board-1", + clientStatus: "queued", + queueTargetRunId: "run-1", + }); + + expect(comment.clientStatus).toBe("queued"); + expect(comment.queueTargetRunId).toBe("run-1"); + }); + + it("merges optimistic comments into the server thread in chronological order", () => { + const merged = mergeIssueComments( + [ + { + id: "comment-2", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Second", + createdAt: new Date("2026-03-28T14:00:02.000Z"), + updatedAt: new Date("2026-03-28T14:00:02.000Z"), + }, + ], + [ + { + id: "optimistic-1", + clientId: "optimistic-1", + clientStatus: "pending", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "First", + createdAt: new Date("2026-03-28T14:00:01.000Z"), + updatedAt: new Date("2026-03-28T14:00:01.000Z"), + }, + ], + ); + + expect(merged.map((comment) => comment.id)).toEqual(["optimistic-1", "comment-2"]); + }); + + it("upserts confirmed comments without creating duplicates", () => { + const next = upsertIssueComment( + [ + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Original", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + ], + { + id: "comment-1", + companyId: "company-1", + issueId: "issue-1", + authorAgentId: null, + authorUserId: "board-1", + body: "Updated", + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:05.000Z"), + }, + ); + + expect(next).toHaveLength(1); + expect(next[0]?.body).toBe("Updated"); + }); + + it("applies optimistic reopen and reassignment updates to the issue cache", () => { + const next = applyOptimisticIssueCommentUpdate( + { + id: "issue-1", + companyId: "company-1", + projectId: null, + projectWorkspaceId: null, + goalId: null, + parentId: null, + title: "Fix comment flow", + description: null, + status: "done", + priority: "medium", + assigneeAgentId: "agent-1", + assigneeUserId: null, + checkoutRunId: null, + executionRunId: null, + executionAgentNameKey: null, + executionLockedAt: null, + createdByAgentId: null, + createdByUserId: "board-1", + issueNumber: 1, + identifier: "PAP-1", + originKind: "manual", + originId: null, + originRunId: null, + requestDepth: 0, + billingCode: null, + assigneeAdapterOverrides: null, + executionWorkspaceId: null, + executionWorkspacePreference: null, + executionWorkspaceSettings: null, + startedAt: null, + completedAt: null, + cancelledAt: null, + hiddenAt: null, + createdAt: new Date("2026-03-28T14:00:00.000Z"), + updatedAt: new Date("2026-03-28T14:00:00.000Z"), + }, + { + reopen: true, + reassignment: { + assigneeAgentId: null, + assigneeUserId: "board-2", + }, + }, + ); + + expect(next?.status).toBe("todo"); + expect(next?.assigneeAgentId).toBeNull(); + expect(next?.assigneeUserId).toBe("board-2"); + }); + + it("treats comments without a run id as queued when they arrive during an active run", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: null, + }), + ).toBe(true); + }); + + it("does not mark comments with an associated run as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + runId: "run-1", + }), + ).toBe(false); + }); + + it("does not mark interrupt comments as queued", () => { + expect( + isQueuedIssueComment({ + comment: { + createdAt: new Date("2026-03-28T16:20:05.000Z"), + }, + activeRunStartedAt: new Date("2026-03-28T16:20:00.000Z"), + interruptedRunId: "run-1", + }), + ).toBe(false); + }); +}); diff --git a/ui/src/lib/optimistic-issue-comments.ts b/ui/src/lib/optimistic-issue-comments.ts new file mode 100644 index 00000000..44d85332 --- /dev/null +++ b/ui/src/lib/optimistic-issue-comments.ts @@ -0,0 +1,123 @@ +import type { Issue, IssueComment } from "@paperclipai/shared"; + +export interface IssueCommentReassignment { + assigneeAgentId: string | null; + assigneeUserId: string | null; +} + +export interface OptimisticIssueComment extends IssueComment { + clientId: string; + clientStatus: "pending" | "queued"; + queueTargetRunId?: string | null; +} + +export type IssueTimelineComment = IssueComment | OptimisticIssueComment; + +function toTimestamp(value: Date | string) { + return new Date(value).getTime(); +} + +function createOptimisticCommentId() { + const randomUuid = globalThis.crypto?.randomUUID?.(); + if (randomUuid) { + return `optimistic-${randomUuid}`; + } + return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function sortIssueComments(comments: T[]) { + return [...comments].sort((a, b) => { + const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt); + if (createdAtDiff !== 0) return createdAtDiff; + return a.id.localeCompare(b.id); + }); +} + +export function createOptimisticIssueComment(params: { + companyId: string; + issueId: string; + body: string; + authorUserId: string | null; + clientStatus?: OptimisticIssueComment["clientStatus"]; + queueTargetRunId?: string | null; +}): OptimisticIssueComment { + const now = new Date(); + const clientId = createOptimisticCommentId(); + return { + id: clientId, + clientId, + companyId: params.companyId, + issueId: params.issueId, + authorAgentId: null, + authorUserId: params.authorUserId, + body: params.body, + clientStatus: params.clientStatus ?? "pending", + queueTargetRunId: params.queueTargetRunId ?? null, + createdAt: now, + updatedAt: now, + }; +} + +export function isQueuedIssueComment(params: { + comment: Pick & Partial>; + activeRunStartedAt?: Date | string | null; + runId?: string | null; + interruptedRunId?: string | null; +}) { + if (params.runId) return false; + if (params.interruptedRunId) return false; + if (params.comment.clientStatus === "queued") return true; + if (!params.activeRunStartedAt) return false; + return toTimestamp(params.comment.createdAt) >= toTimestamp(params.activeRunStartedAt); +} + +export function mergeIssueComments( + comments: IssueComment[] | undefined, + optimisticComments: OptimisticIssueComment[], +): IssueTimelineComment[] { + const merged = [...(comments ?? [])]; + const existingIds = new Set(merged.map((comment) => comment.id)); + for (const comment of optimisticComments) { + if (!existingIds.has(comment.id)) { + merged.push(comment); + } + } + return sortIssueComments(merged); +} + +export function upsertIssueComment( + comments: IssueComment[] | undefined, + nextComment: IssueComment, +): IssueComment[] { + const current = comments ?? []; + const existingIndex = current.findIndex((comment) => comment.id === nextComment.id); + if (existingIndex === -1) { + return sortIssueComments([...current, nextComment]); + } + + const updated = [...current]; + updated[existingIndex] = nextComment; + return sortIssueComments(updated); +} + +export function applyOptimisticIssueCommentUpdate( + issue: Issue | undefined, + params: { + reopen?: boolean; + reassignment?: IssueCommentReassignment; + }, +) { + if (!issue) return issue; + const nextIssue: Issue = { ...issue }; + + if (params.reopen === true && (issue.status === "done" || issue.status === "cancelled")) { + nextIssue.status = "todo"; + } + + if (params.reassignment) { + nextIssue.assigneeAgentId = params.reassignment.assigneeAgentId; + nextIssue.assigneeUserId = params.reassignment.assigneeUserId; + } + + return nextIssue; +} diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts new file mode 100644 index 00000000..0dcb70c8 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from "vitest"; +import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared"; +import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab"; + +function createProjectWorkspace(overrides: Partial): ProjectWorkspace { + return { + id: overrides.id ?? "workspace-default", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + name: overrides.name ?? "paperclip", + sourceType: overrides.sourceType ?? "local_path", + cwd: overrides.cwd ?? "/repo", + repoUrl: overrides.repoUrl ?? null, + repoRef: overrides.repoRef ?? null, + defaultRef: overrides.defaultRef ?? null, + visibility: overrides.visibility ?? "default", + setupCommand: overrides.setupCommand ?? null, + cleanupCommand: overrides.cleanupCommand ?? null, + remoteProvider: overrides.remoteProvider ?? null, + remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null, + sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null, + metadata: overrides.metadata ?? null, + runtimeConfig: overrides.runtimeConfig ?? null, + isPrimary: overrides.isPrimary ?? false, + runtimeServices: overrides.runtimeServices ?? [], + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + }; +} + +function createIssue(overrides: Partial): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? null, + goalId: overrides.goalId ?? null, + parentId: overrides.parentId ?? null, + title: overrides.title ?? "Issue", + description: overrides.description ?? null, + status: overrides.status ?? "todo", + priority: overrides.priority ?? "medium", + assigneeAgentId: overrides.assigneeAgentId ?? null, + assigneeUserId: overrides.assigneeUserId ?? null, + checkoutRunId: overrides.checkoutRunId ?? null, + executionRunId: overrides.executionRunId ?? null, + executionAgentNameKey: overrides.executionAgentNameKey ?? null, + executionLockedAt: overrides.executionLockedAt ?? null, + createdByAgentId: overrides.createdByAgentId ?? null, + createdByUserId: overrides.createdByUserId ?? null, + issueNumber: overrides.issueNumber ?? null, + identifier: overrides.identifier ?? null, + requestDepth: overrides.requestDepth ?? 0, + billingCode: overrides.billingCode ?? null, + assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null, + executionWorkspaceId: overrides.executionWorkspaceId ?? null, + executionWorkspacePreference: overrides.executionWorkspacePreference ?? null, + executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null, + startedAt: overrides.startedAt ?? null, + completedAt: overrides.completedAt ?? null, + cancelledAt: overrides.cancelledAt ?? null, + hiddenAt: overrides.hiddenAt ?? null, + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + } as Issue; +} + +function createExecutionWorkspace(overrides: Partial): ExecutionWorkspace { + return { + id: overrides.id ?? "exec-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-default", + sourceIssueId: overrides.sourceIssueId ?? null, + mode: overrides.mode ?? "isolated_workspace", + strategyType: overrides.strategyType ?? "git_worktree", + name: overrides.name ?? "PAP-893", + status: overrides.status ?? "active", + cwd: overrides.cwd ?? "/repo/.worktrees/PAP-893", + repoUrl: overrides.repoUrl ?? null, + baseRef: overrides.baseRef ?? "public-gh/master", + branchName: overrides.branchName ?? "PAP-893-workspaces-tab", + providerType: overrides.providerType ?? "git_worktree", + providerRef: overrides.providerRef ?? null, + derivedFromExecutionWorkspaceId: overrides.derivedFromExecutionWorkspaceId ?? null, + lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"), + openedAt: overrides.openedAt ?? new Date("2026-03-26T09:00:00Z"), + closedAt: overrides.closedAt ?? null, + cleanupEligibleAt: overrides.cleanupEligibleAt ?? null, + cleanupReason: overrides.cleanupReason ?? null, + config: overrides.config ?? null, + metadata: overrides.metadata ?? null, + runtimeServices: overrides.runtimeServices ?? [], + createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"), + }; +} + +describe("buildProjectWorkspaceSummaries", () => { + const primaryWorkspace = createProjectWorkspace({ + id: "workspace-default", + isPrimary: true, + name: "paperclip", + }); + const featureWorkspace = createProjectWorkspace({ + id: "workspace-feature", + name: "feature-checkout", + repoRef: "feature/workspaces", + updatedAt: new Date("2026-03-25T09:00:00Z"), + }); + const project = { + workspaces: [primaryWorkspace, featureWorkspace], + primaryWorkspace, + } satisfies Pick; + + it("groups isolated execution workspace issues ahead of shared non-primary workspace issues", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-primary", + projectWorkspaceId: primaryWorkspace.id, + updatedAt: new Date("2026-03-26T08:00:00Z"), + }), + createIssue({ + id: "issue-feature-older", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-800", + updatedAt: new Date("2026-03-25T10:00:00Z"), + }), + createIssue({ + id: "issue-feature-newer", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-801", + updatedAt: new Date("2026-03-25T11:00:00Z"), + }), + createIssue({ + id: "issue-exec", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-1", + identifier: "PAP-893", + updatedAt: new Date("2026-03-26T11:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-1", + name: "PAP-893", + branchName: "PAP-893-workspaces-tab", + lastUsedAt: new Date("2026-03-26T10:30:00Z"), + }), + ], + }); + + expect(summaries).toHaveLength(3); + expect(summaries[0]).toMatchObject({ + key: "execution:exec-1", + kind: "execution_workspace", + workspaceName: "PAP-893", + branchName: "PAP-893-workspaces-tab", + executionWorkspaceId: "exec-1", + }); + expect(summaries[0]?.issues.map((issue) => issue.id)).toEqual(["issue-exec"]); + + expect(summaries[1]).toMatchObject({ + key: "project:workspace-feature", + kind: "project_workspace", + workspaceName: "feature-checkout", + branchName: "feature/workspaces", + projectWorkspaceId: "workspace-feature", + }); + expect(summaries[1]?.issues.map((issue) => issue.id)).toEqual([ + "issue-feature-newer", + "issue-feature-older", + ]); + expect(summaries[2]?.key).toBe("project:workspace-default"); + }); + + it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-exec-derived", + projectWorkspaceId: featureWorkspace.id, + executionWorkspaceId: "exec-2", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-2", + projectWorkspaceId: featureWorkspace.id, + name: "feature-branch run", + }), + ], + }); + + expect(summaries).toHaveLength(2); + expect(summaries[0]?.key).toBe("execution:exec-2"); + expect(summaries[1]?.key).toBe("project:workspace-default"); + }); + + it("excludes issues that only use the default shared workspace", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-default-shared", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-shared-default", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-shared-default", + mode: "shared_workspace", + strategyType: "project_primary", + projectWorkspaceId: primaryWorkspace.id, + branchName: null, + baseRef: null, + providerType: "local_fs", + }), + ], + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.key).toBe("project:workspace-default"); + }); +}); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts new file mode 100644 index 00000000..df0d9bb3 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.ts @@ -0,0 +1,172 @@ +import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared"; + +type ProjectWorkspaceLike = Pick; + +export interface ProjectWorkspaceSummary { + key: string; + kind: "execution_workspace" | "project_workspace"; + workspaceId: string; + workspaceName: string; + cwd: string | null; + branchName: string | null; + lastUpdatedAt: Date; + projectWorkspaceId: string | null; + executionWorkspaceId: string | null; + executionWorkspaceStatus: ExecutionWorkspace["status"] | null; + serviceCount: number; + runningServiceCount: number; + primaryServiceUrl: string | null; + hasRuntimeConfig: boolean; + issues: Issue[]; +} + +function toDate(value: Date | string | null | undefined): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function maxDate(...values: Array): Date { + let latest = new Date(0); + for (const value of values) { + const date = toDate(value); + if (date && date.getTime() > latest.getTime()) latest = date; + } + return latest; +} + +function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null { + return project.primaryWorkspace?.id + ?? project.workspaces.find((workspace) => workspace.isPrimary)?.id + ?? project.workspaces[0]?.id + ?? null; +} + +function isDefaultSharedExecutionWorkspace(input: { + executionWorkspace: ExecutionWorkspace; + issue: Issue; + primaryWorkspaceId: string | null; +}) { + const linkedProjectWorkspaceId = + input.executionWorkspace.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null; + return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId; +} + +export function buildProjectWorkspaceSummaries(input: { + project: ProjectWorkspaceLike; + issues: Issue[]; + executionWorkspaces: ExecutionWorkspace[]; +}): ProjectWorkspaceSummary[] { + const primaryId = primaryWorkspaceId(input.project); + const executionWorkspacesById = new Map( + input.executionWorkspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const projectWorkspacesById = new Map( + input.project.workspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const summaries = new Map(); + + for (const issue of input.issues) { + if (issue.executionWorkspaceId) { + const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId); + if (!executionWorkspace) continue; + if (executionWorkspace.status === "archived") continue; + if (isDefaultSharedExecutionWorkspace({ + executionWorkspace, + issue, + primaryWorkspaceId: primaryId, + })) continue; + + const existing = summaries.get(`execution:${executionWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`execution:${executionWorkspace.id}`, { + key: `execution:${executionWorkspace.id}`, + kind: "execution_workspace", + workspaceId: executionWorkspace.id, + workspaceName: executionWorkspace.name, + cwd: executionWorkspace.cwd ?? null, + branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null, + lastUpdatedAt: maxDate( + existing?.lastUpdatedAt, + executionWorkspace.lastUsedAt, + executionWorkspace.updatedAt, + issue.updatedAt, + ), + projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null, + executionWorkspaceId: executionWorkspace.id, + executionWorkspaceStatus: executionWorkspace.status, + serviceCount: executionWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: executionWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: executionWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean( + executionWorkspace.config?.workspaceRuntime + ?? projectWorkspacesById.get(executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? "")?.runtimeConfig?.workspaceRuntime, + ), + issues: nextIssues, + }); + continue; + } + + if (!issue.projectWorkspaceId || issue.projectWorkspaceId === primaryId) continue; + const projectWorkspace = projectWorkspacesById.get(issue.projectWorkspaceId); + if (!projectWorkspace) continue; + + const existing = summaries.get(`project:${projectWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`project:${projectWorkspace.id}`, { + key: `project:${projectWorkspace.id}`, + kind: "project_workspace", + workspaceId: projectWorkspace.id, + workspaceName: projectWorkspace.name, + cwd: projectWorkspace.cwd ?? null, + branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, + lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt), + projectWorkspaceId: projectWorkspace.id, + executionWorkspaceId: null, + executionWorkspaceStatus: null, + serviceCount: projectWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), + issues: nextIssues, + }); + } + + for (const projectWorkspace of input.project.workspaces) { + const key = `project:${projectWorkspace.id}`; + if (summaries.has(key)) continue; + const shouldSurfaceWorkspace = + projectWorkspace.isPrimary + || Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime) + || (projectWorkspace.runtimeServices?.length ?? 0) > 0; + if (!shouldSurfaceWorkspace) continue; + summaries.set(key, { + key, + kind: "project_workspace", + workspaceId: projectWorkspace.id, + workspaceName: projectWorkspace.name, + cwd: projectWorkspace.cwd ?? null, + branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, + lastUpdatedAt: maxDate(projectWorkspace.updatedAt), + projectWorkspaceId: projectWorkspace.id, + executionWorkspaceId: null, + executionWorkspaceStatus: null, + serviceCount: projectWorkspace.runtimeServices?.length ?? 0, + runningServiceCount: projectWorkspace.runtimeServices?.filter((service) => service.status === "running").length ?? 0, + primaryServiceUrl: projectWorkspace.runtimeServices?.find((service) => service.url)?.url ?? null, + hasRuntimeConfig: Boolean(projectWorkspace.runtimeConfig?.workspaceRuntime), + issues: [], + }); + } + + return [...summaries.values()].sort((a, b) => { + const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime(); + return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName); + }); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index d35967cf..61d01d39 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -25,17 +25,22 @@ export const queryKeys = { configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => ["agents", companyId, "adapter-models", adapterType] as const, + detectModel: (companyId: string, adapterType: string) => + ["agents", companyId, "detect-model", adapterType] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, search: (companyId: string, q: string, projectId?: string) => ["issues", companyId, "search", q, projectId ?? "__all-projects__"] as const, listAssignedToMe: (companyId: string) => ["issues", companyId, "assigned-to-me"] as const, + listMineByMe: (companyId: string) => ["issues", companyId, "mine-by-me"] as const, listTouchedByMe: (companyId: string) => ["issues", companyId, "touched-by-me"] as const, listUnreadTouchedByMe: (companyId: string) => ["issues", companyId, "unread-touched-by-me"] as const, labels: (companyId: string) => ["issues", companyId, "labels"] as const, listByProject: (companyId: string, projectId: string) => ["issues", companyId, "project", projectId] as const, + listByExecutionWorkspace: (companyId: string, executionWorkspaceId: string) => + ["issues", companyId, "execution-workspace", executionWorkspaceId] as const, detail: (id: string) => ["issues", "detail", id] as const, comments: (issueId: string) => ["issues", "comments", issueId] as const, attachments: (issueId: string) => ["issues", "attachments", issueId] as const, @@ -58,6 +63,8 @@ export const queryKeys = { list: (companyId: string, filters?: Record) => ["execution-workspaces", companyId, filters ?? {}] as const, detail: (id: string) => ["execution-workspaces", "detail", id] as const, + closeReadiness: (id: string) => ["execution-workspaces", "close-readiness", id] as const, + workspaceOperations: (id: string) => ["execution-workspaces", "workspace-operations", id] as const, }, projects: { list: (companyId: string) => ["projects", companyId] as const, diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index dcab46c1..76e18846 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -165,3 +165,11 @@ export function projectRouteRef(project: { id: string; urlKey?: string | null; n export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string { return `/projects/${projectRouteRef(project)}`; } + +/** Build a project workspace URL scoped under its project. */ +export function projectWorkspaceUrl( + project: { id: string; urlKey?: string | null; name?: string | null }, + workspaceId: string, +): string { + return `${projectUrl(project)}/workspaces/${workspaceId}`; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c0bed886..3e19d294 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin const isLive = run.status === "running" || run.status === "queued"; const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const StatusIcon = statusInfo.icon; - const summary = run.resultJson + const summaryRaw = run.resultJson ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") : run.error ?? ""; + // Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines + const summary = useMemo(() => { + if (!summaryRaw) return ""; + const lines = summaryRaw + .replace(/^#{1,6}\s+/gm, "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l)); + const excerpt: string[] = []; + let chars = 0; + for (const line of lines) { + if (excerpt.length >= 3 || chars + line.length > 280) break; + excerpt.push(line); + chars += line.length; + } + return excerpt.join(" "); + }, [summaryRaw]); + return (
@@ -2351,6 +2369,7 @@ function AgentSkillsTab({ const queryClient = useQueryClient(); const [skillDraft, setSkillDraft] = useState([]); const [lastSavedSkills, setLastSavedSkills] = useState([]); + const [unmanagedOpen, setUnmanagedOpen] = useState(false); const lastSavedSkillsRef = useRef([]); const hasHydratedSkillSnapshotRef = useRef(false); const skipNextSkillAutosaveRef = useRef(true); @@ -2680,12 +2699,19 @@ function AgentSkillsTab({ {unmanagedSkillRows.length > 0 && (
-
+
setUnmanagedOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }} + > - User-installed skills, not managed by Paperclip + ({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip + {unmanagedOpen ? : }
- {unmanagedSkillRows.map(renderSkillRow)} + {unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
)} diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index b14112c4..a157f777 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -26,6 +26,7 @@ const adapterLabels: Record = { gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", + hermes_local: "Hermes", openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", diff --git a/ui/src/pages/CompanySettings.tsx b/ui/src/pages/CompanySettings.tsx index e09f67c7..2e7b69ee 100644 --- a/ui/src/pages/CompanySettings.tsx +++ b/ui/src/pages/CompanySettings.tsx @@ -377,7 +377,7 @@ export function CompanySettings() { )} {/* Hiring */} -
+
Hiring
@@ -387,12 +387,13 @@ export function CompanySettings() { hint="New agent hires stay pending until approved by board." checked={!!selectedCompany.requireBoardApprovalForNewAgents} onChange={(v) => settingsMutation.mutate(v)} + toggleTestId="company-settings-team-approval-toggle" />
{/* Invites */} -
+
Invites
@@ -405,6 +406,7 @@ export function CompanySettings() {