diff --git a/.agents/skills/deal-with-security-advisory/SKILL.md b/.agents/skills/deal-with-security-advisory/SKILL.md new file mode 100644 index 00000000000..0ba122366ec --- /dev/null +++ b/.agents/skills/deal-with-security-advisory/SKILL.md @@ -0,0 +1,230 @@ +--- +name: deal-with-security-advisory +description: > + Handle a GitHub Security Advisory response for Paperclip, including + confidential fix development in a temporary private fork, human coordination + on advisory-thread comments, CVE request, synchronized advisory publication, + and immediate security release steps. +--- + +# Security Vulnerability Response Instructions + +## ⚠️ CRITICAL: This is a security vulnerability. Everything about this process is confidential until the advisory is published. Do not mention the vulnerability details in any public commit message, PR title, branch name, or comment. Do not push anything to a public branch. Do not discuss specifics in any public channel. Assume anything on the public repo is visible to attackers who will exploit the window between disclosure and user upgrades. + +*** + +## Context + +A security vulnerability has been reported via GitHub Security Advisory: + +* **Advisory:** {{ghsaId}} (e.g. GHSA-x8hx-rhr2-9rf7) +* **Reporter:** {{reporterHandle}} +* **Severity:** {{severity}} +* **Notes:** {{notes}} + +*** + +## Step 0: Fetch the Advisory Details + +Pull the full advisory so you understand the vulnerability before doing anything else: + +``` +gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} + +``` + +Read the `description`, `severity`, `cvss`, and `vulnerabilities` fields. Understand the attack vector before writing code. + +## Step 1: Acknowledge the Report + +⚠️ **This step requires a human.** The advisory thread does not have a comment API. Ask the human operator to post a comment on the private advisory thread acknowledging the report. Provide them this template: + +> Thanks for the report, @{{reporterHandle}}. We've confirmed the issue and are working on a fix. We're targeting a patch release within {{timeframe}}. We'll keep you updated here. + +Give your human this template, but still continue + +Below we use `gh` tools - you do have access and credentials outside of your sandbox, so use them. + +## Step 2: Create the Temporary Private Fork + +This is where all fix development happens. Never push to the public repo. + +``` +gh api --method POST \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/forks + +``` + +This returns a repository object for the private fork. Save the `full_name` and `clone_url`. + +Clone it and set up your workspace: + +``` +# Clone the private fork somewhere outside ~/paperclip +git clone ~/security-patch-{{ghsaId}} +cd ~/security-patch-{{ghsaId}} +git checkout -b security-fix + +``` + +**Do not edit `~/paperclip`** — the dev server is running off the `~/paperclip` master branch and we don't want to touch it. All work happens in the private fork clone. + +**TIPS:** + +* Do not commit `pnpm-lock.yaml` — the repo has actions to manage this +* Do not use descriptive branch names that leak the vulnerability (e.g., no `fix-dns-rebinding-rce`). Use something generic like `security-fix` +* All work stays in the private fork until publication +* CI/GitHub Actions will NOT run on the temporary private fork — this is a GitHub limitation by design. You must run tests locally + +## Step 3: Develop and Validate the Fix + +Write the patch. Same content standards as any PR: + +* It must functionally work — **run tests locally** since CI won't run on the private fork +* Consider the whole codebase, not just the narrow vulnerability path. A patch that fixes one vector but opens another is worse than no patch +* Ensure backwards compatibility for the database, or be explicit about what breaks +* Make sure any UI components still look correct if the fix touches them +* The fix should be minimal and focused — don't bundle unrelated changes into a security patch. Reviewers (and the reporter) should be able to read the diff and understand exactly what changed and why + +**Specific to security fixes:** + +* Verify the fix actually closes the attack vector described in the advisory. Reproduce the vulnerability first (using the reporter's description), then confirm the patch prevents it +* Consider adjacent attack vectors — if DNS rebinding is the issue, are there other endpoints or modes with the same class of problem? +* Do not introduce new dependencies unless absolutely necessary — new deps in a security patch raise eyebrows + +Push your fix to the private fork: + +``` +git add -A +git commit -m "Fix security vulnerability" +git push origin security-fix + +``` + +## Step 4: Coordinate with the Reporter + +⚠️ **This step requires a human.** Ask the human operator to post on the advisory thread letting the reporter know the fix is ready and giving them a chance to review. Provide them this template: + +> @{{reporterHandle}} — fix is ready in the private fork if you'd like to review before we publish. Planning to release within {{timeframe}}. + +Proceed + +## Step 5: Request a CVE + +This makes vulnerability scanners (npm audit, Snyk, Dependabot) warn users to upgrade. Without it, nobody gets automated notification. + +``` +gh api --method POST \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}}/cve + +``` + +GitHub is a CVE Numbering Authority and will assign one automatically. The CVE may take a few hours to propagate after the advisory is published. + +## Step 6: Publish Everything Simultaneously + +This all happens at once — do not stagger these steps. The goal is **zero window** between the vulnerability becoming public knowledge and the fix being available. + +### 6a. Verify reporter credit before publishing + +``` +gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} --jq '.credits' + +``` + +If the reporter is not credited, add them: + +``` +gh api --method PATCH \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \ + --input - << 'EOF' +{ + "credits": [ + { + "login": "{{reporterHandle}}", + "type": "reporter" + } + ] +} +EOF + +``` + +### 6b. Update the advisory with the patched version and publish + +``` +gh api --method PATCH \ + repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \ + --input - << 'EOF' +{ + "state": "published", + "vulnerabilities": [ + { + "package": { + "ecosystem": "npm", + "name": "paperclip" + }, + "vulnerable_version_range": "< {{patchedVersion}}", + "patched_versions": "{{patchedVersion}}" + } + ] +} +EOF + +``` + +Publishing the advisory simultaneously: + +* Makes the GHSA public +* Merges the temporary private fork into your repo +* Triggers the CVE assignment (if requested in step 5) + +### 6c. Cut a release immediately after merge + +``` +cd ~/paperclip +git pull origin master + +gh release create v{{patchedVersion}} \ + --repo paperclipai/paperclip \ + --title "v{{patchedVersion}} — Security Release" \ + --notes "## Security Release + +This release fixes a critical security vulnerability. + +### What was fixed +{{briefDescription}} (e.g., Remote code execution via DNS rebinding in \`local_trusted\` mode) + +### Advisory +https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}} + +### Credit +Thanks to @{{reporterHandle}} for responsibly disclosing this vulnerability. + +### Action required +All users running versions prior to {{patchedVersion}} should upgrade immediately." + +``` + +## Step 7: Post-Publication Verification + +``` +# Verify the advisory is published and CVE is assigned +gh api repos/paperclipai/paperclip/security-advisories/{{ghsaId}} \ + --jq '{state: .state, cve_id: .cve_id, published_at: .published_at}' + +# Verify the release exists +gh release view v{{patchedVersion}} --repo paperclipai/paperclip + +``` + +If the CVE hasn't been assigned yet, that's normal — it can take a few hours. + +⚠️ **Human step:** Ask the human operator to post a final comment on the advisory thread confirming publication and thanking the reporter. + +Tell the human operator what you did by posting a comment to this task, including: + +* The published advisory URL: `https://github.com/paperclipai/paperclip/security/advisories/{{ghsaId}}` +* The release URL +* Whether the CVE has been assigned yet +* All URLs to any pull requests or branches diff --git a/.mailmap b/.mailmap index 4a1dd6697a2..31780e745b2 100644 --- a/.mailmap +++ b/.mailmap @@ -1 +1,3 @@ -Dotta Forgotten +Dotta <34892728+cryppadotta@users.noreply.github.com> +Dotta +Dotta diff --git a/README.md b/README.md index c45776e74df..bff4b1c71a7 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,14 @@ Open source. Self-hosted. No Paperclip account required. npx paperclipai onboard --yes ``` +That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly: + +```bash +npx paperclipai onboard --yes --bind lan +# or: +npx paperclipai onboard --yes --bind tailnet +``` + If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. Or manually: @@ -243,11 +251,18 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. - ✅ Skills Manager - ✅ Scheduled Routines - ✅ Better Budgeting -- ⚪ Artifacts & Deployments -- ⚪ CEO Chat -- ⚪ MAXIMIZER MODE +- ✅ Agent Reviews and Approvals - ⚪ Multiple Human Users - ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Artifacts & Work Products +- ⚪ Memory & Knowledge +- ⚪ Enforced Outcomes +- ⚪ MAXIMIZER MODE +- ⚪ Deep Planning +- ⚪ Work Queues +- ⚪ Self-Organization +- ⚪ Automatic Organizational Learning +- ⚪ CEO Chat - ⚪ Cloud deployments - ⚪ Desktop App @@ -263,12 +278,12 @@ Paperclip collects anonymous usage telemetry to help us understand how the produ Telemetry is **enabled by default** and can be disabled with any of the following: -| Method | How | -|---|---| -| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` | -| Standard convention | `DO_NOT_TRACK=1` | -| CI environments | Automatically disabled when `CI=true` | -| Config file | Set `telemetry.enabled: false` in your Paperclip config | +| Method | How | +| -------------------- | ------------------------------------------------------- | +| Environment variable | `PAPERCLIP_TELEMETRY_DISABLED=1` | +| Standard convention | `DO_NOT_TRACK=1` | +| CI environments | Automatically disabled when `CI=true` | +| Config file | Set `telemetry.enabled: false` in your Paperclip config | ## Contributing diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..ccc4b6a03d2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,8 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities through GitHub's Security Advisory feature: +[https://github.com/paperclipai/paperclip/security/advisories/new](https://github.com/paperclipai/paperclip/security/advisories/new) + +Do not open public issues for security vulnerabilities. diff --git a/cli/README.md b/cli/README.md index 08f36ad49b3..9eff1a397b2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -177,6 +177,14 @@ Open source. Self-hosted. No Paperclip account required. npx paperclipai onboard --yes ``` +That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly: + +```bash +npx paperclipai onboard --yes --bind lan +# or: +npx paperclipai onboard --yes --bind tailnet +``` + If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. Or manually: diff --git a/cli/src/__tests__/network-bind.test.ts b/cli/src/__tests__/network-bind.test.ts new file mode 100644 index 00000000000..d75452abafd --- /dev/null +++ b/cli/src/__tests__/network-bind.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { resolveRuntimeBind, validateConfiguredBindMode } from "@paperclipai/shared"; +import { buildPresetServerConfig } from "../config/server-bind.js"; + +describe("network bind helpers", () => { + it("rejects non-loopback bind modes in local_trusted", () => { + expect( + validateConfiguredBindMode({ + deploymentMode: "local_trusted", + deploymentExposure: "private", + bind: "lan", + host: "0.0.0.0", + }), + ).toContain("local_trusted requires server.bind=loopback"); + }); + + it("resolves tailnet bind using the detected tailscale address", () => { + const resolved = resolveRuntimeBind({ + bind: "tailnet", + host: "127.0.0.1", + tailnetBindHost: "100.64.0.8", + }); + + expect(resolved.errors).toEqual([]); + expect(resolved.host).toBe("100.64.0.8"); + }); + + it("requires a custom bind host when bind=custom", () => { + const resolved = resolveRuntimeBind({ + bind: "custom", + host: "127.0.0.1", + }); + + expect(resolved.errors).toContain("server.customBindHost is required when server.bind=custom"); + }); + + it("stores the detected tailscale address for tailnet presets", () => { + process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8"; + + const preset = buildPresetServerConfig("tailnet", { + port: 3100, + allowedHostnames: [], + serveUi: true, + }); + + expect(preset.server.host).toBe("100.64.0.8"); + + delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + }); + + it("falls back to loopback when no tailscale address is available for tailnet presets", () => { + delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + + const preset = buildPresetServerConfig("tailnet", { + port: 3100, + allowedHostnames: [], + serveUi: true, + }); + + expect(preset.server.host).toBe("127.0.0.1"); + }); +}); diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index df1a91b8103..b66c20fa8c1 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -74,6 +74,11 @@ function createExistingConfigFixture() { return { configPath, configText: fs.readFileSync(configPath, "utf8") }; } +function createFreshConfigPath() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-fresh-")); + return path.join(root, ".paperclip", "config.json"); +} + describe("onboard", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; @@ -105,4 +110,57 @@ describe("onboard", () => { expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); }); + + it("keeps --yes onboarding on local trusted loopback defaults", async () => { + const configPath = createFreshConfigPath(); + process.env.HOST = "0.0.0.0"; + process.env.PAPERCLIP_BIND = "lan"; + + await onboard({ config: configPath, yes: true, invokedByRun: true }); + + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; + expect(raw.server.deploymentMode).toBe("local_trusted"); + expect(raw.server.exposure).toBe("private"); + expect(raw.server.bind).toBe("loopback"); + expect(raw.server.host).toBe("127.0.0.1"); + }); + + it("supports authenticated/private quickstart bind presets", async () => { + const configPath = createFreshConfigPath(); + process.env.PAPERCLIP_TAILNET_BIND_HOST = "100.64.0.8"; + + await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; + expect(raw.server.deploymentMode).toBe("authenticated"); + expect(raw.server.exposure).toBe("private"); + expect(raw.server.bind).toBe("tailnet"); + expect(raw.server.host).toBe("100.64.0.8"); + }); + + it("keeps tailnet quickstart on loopback until tailscale is available", async () => { + const configPath = createFreshConfigPath(); + delete process.env.PAPERCLIP_TAILNET_BIND_HOST; + + await onboard({ config: configPath, yes: true, invokedByRun: true, bind: "tailnet" }); + + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; + expect(raw.server.deploymentMode).toBe("authenticated"); + expect(raw.server.exposure).toBe("private"); + expect(raw.server.bind).toBe("tailnet"); + expect(raw.server.host).toBe("127.0.0.1"); + }); + + it("ignores deployment env overrides during --yes quickstart", async () => { + const configPath = createFreshConfigPath(); + process.env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated"; + + await onboard({ config: configPath, yes: true, invokedByRun: true }); + + const raw = JSON.parse(fs.readFileSync(configPath, "utf8")) as PaperclipConfig; + expect(raw.server.deploymentMode).toBe("local_trusted"); + expect(raw.server.exposure).toBe("private"); + expect(raw.server.bind).toBe("loopback"); + expect(raw.server.host).toBe("127.0.0.1"); + }); }); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 774f5649cf6..631ea217f62 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -2,11 +2,21 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; -import { authUsers, createDb, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "@paperclipai/db"; +import { randomUUID } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + agents, + authUsers, + companies, + createDb, + projects, + routines, + routineTriggers, +} from "@paperclipai/db"; import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, + pauseSeededScheduledRoutines, readSourceAttachmentBody, rebindWorkspaceCwd, resolveSourceConfigPath, @@ -29,11 +39,22 @@ import { sanitizeWorktreeInstanceId, } from "../commands/worktree-lib.js"; import type { PaperclipConfig } from "../config/schema.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; const ORIGINAL_CWD = process.cwd(); const ORIGINAL_ENV = { ...process.env }; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip; +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} afterEach(() => { process.chdir(ORIGINAL_CWD); @@ -917,3 +938,138 @@ describe("worktree helpers", () => { } }, 20_000); }); + +describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => { + it("pauses only routines with enabled schedule triggers", async () => { + const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-routines-"); + const db = createDb(tempDb.connectionString); + const companyId = randomUUID(); + const projectId = randomUUID(); + const agentId = randomUUID(); + const activeScheduledRoutineId = randomUUID(); + const activeApiRoutineId = randomUUID(); + const pausedScheduledRoutineId = randomUUID(); + const archivedScheduledRoutineId = randomUUID(); + const disabledScheduleRoutineId = randomUUID(); + + try { + 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: "Coder", + adapterType: "process", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Project", + status: "in_progress", + }); + await db.insert(routines).values([ + { + id: activeScheduledRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Active scheduled", + status: "active", + }, + { + id: activeApiRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Active API", + status: "active", + }, + { + id: pausedScheduledRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Paused scheduled", + status: "paused", + }, + { + id: archivedScheduledRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Archived scheduled", + status: "archived", + }, + { + id: disabledScheduleRoutineId, + companyId, + projectId, + assigneeAgentId: agentId, + title: "Disabled schedule", + status: "active", + }, + ]); + await db.insert(routineTriggers).values([ + { + companyId, + routineId: activeScheduledRoutineId, + kind: "schedule", + enabled: true, + cronExpression: "0 9 * * *", + timezone: "UTC", + }, + { + companyId, + routineId: activeApiRoutineId, + kind: "api", + enabled: true, + }, + { + companyId, + routineId: pausedScheduledRoutineId, + kind: "schedule", + enabled: true, + cronExpression: "0 10 * * *", + timezone: "UTC", + }, + { + companyId, + routineId: archivedScheduledRoutineId, + kind: "schedule", + enabled: true, + cronExpression: "0 11 * * *", + timezone: "UTC", + }, + { + companyId, + routineId: disabledScheduleRoutineId, + kind: "schedule", + enabled: false, + cronExpression: "0 12 * * *", + timezone: "UTC", + }, + ]); + + const pausedCount = await pauseSeededScheduledRoutines(tempDb.connectionString); + expect(pausedCount).toBe(1); + + const rows = await db.select({ id: routines.id, status: routines.status }).from(routines); + const statusById = new Map(rows.map((row) => [row.id, row.status])); + expect(statusById.get(activeScheduledRoutineId)).toBe("paused"); + expect(statusById.get(activeApiRoutineId)).toBe("active"); + expect(statusById.get(pausedScheduledRoutineId)).toBe("paused"); + expect(statusById.get(archivedScheduledRoutineId)).toBe("archived"); + expect(statusById.get(disabledScheduleRoutineId)).toBe("active"); + } finally { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); + await tempDb.cleanup(); + } + }, 20_000); +}); diff --git a/cli/src/checks/deployment-auth-check.ts b/cli/src/checks/deployment-auth-check.ts index 580e7e08f39..6434ede07cf 100644 --- a/cli/src/checks/deployment-auth-check.ts +++ b/cli/src/checks/deployment-auth-check.ts @@ -1,24 +1,21 @@ +import { inferBindModeFromHost } from "@paperclipai/shared"; import type { PaperclipConfig } from "../config/schema.js"; import type { CheckResult } from "./index.js"; -function isLoopbackHost(host: string) { - const normalized = host.trim().toLowerCase(); - return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; -} - export function deploymentAuthCheck(config: PaperclipConfig): CheckResult { const mode = config.server.deploymentMode; const exposure = config.server.exposure; const auth = config.auth; + const bind = config.server.bind ?? inferBindModeFromHost(config.server.host); if (mode === "local_trusted") { - if (!isLoopbackHost(config.server.host)) { + if (bind !== "loopback") { return { name: "Deployment/auth mode", status: "fail", - message: `local_trusted requires loopback host binding (found ${config.server.host})`, + message: `local_trusted requires loopback binding (found ${bind})`, canRepair: false, - repairHint: "Run `paperclipai configure --section server` and set host to 127.0.0.1", + repairHint: "Run `paperclipai configure --section server` and choose Local trusted / loopback reachability", }; } return { @@ -86,6 +83,6 @@ export function deploymentAuthCheck(config: PaperclipConfig): CheckResult { return { name: "Deployment/auth mode", status: "pass", - message: `Mode ${mode}/${exposure} with auth URL mode ${auth.baseUrlMode}`, + message: `Mode ${mode}/${exposure} with bind ${bind} and auth URL mode ${auth.baseUrlMode}`, }; } diff --git a/cli/src/commands/auth-bootstrap-ceo.ts b/cli/src/commands/auth-bootstrap-ceo.ts index dc720f63d8e..d07b9a70392 100644 --- a/cli/src/commands/auth-bootstrap-ceo.ts +++ b/cli/src/commands/auth-bootstrap-ceo.ts @@ -3,6 +3,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { and, eq, gt, isNull } from "drizzle-orm"; import { createDb, instanceUserRoles, invites } from "@paperclipai/db"; +import { inferBindModeFromHost } from "@paperclipai/shared"; import { loadPaperclipEnvFile } from "../config/env.js"; import { readConfig, resolveConfigPath } from "../config/store.js"; @@ -40,9 +41,13 @@ function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) { if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { return config.auth.publicBaseUrl.replace(/\/+$/, ""); } - const host = config?.server.host ?? "localhost"; + const bind = config?.server.bind ?? inferBindModeFromHost(config?.server.host); + const host = + bind === "custom" + ? config?.server.customBindHost ?? config?.server.host ?? "localhost" + : config?.server.host ?? "localhost"; const port = config?.server.port ?? 3100; - const publicHost = host === "0.0.0.0" ? "localhost" : host; + const publicHost = host === "0.0.0.0" || bind === "lan" ? "localhost" : host; return `http://${publicHost}:${port}`; } diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index 83ff089be61..b7d2dcbc07d 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -54,6 +54,7 @@ function defaultConfig(): PaperclipConfig { server: { deploymentMode: "local_trusted", exposure: "private", + bind: "loopback", host: "127.0.0.1", port: 3100, allowedHostnames: [], diff --git a/cli/src/commands/db-backup.ts b/cli/src/commands/db-backup.ts index bdbf739f44b..7e1adce546a 100644 --- a/cli/src/commands/db-backup.ts +++ b/cli/src/commands/db-backup.ts @@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise { const result = await runDatabaseBackup({ connectionString: connection.value, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix, }); spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index d9b325a8c17..62158e05bbf 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -3,10 +3,14 @@ import path from "node:path"; import pc from "picocolors"; import { AUTH_BASE_URL_MODES, + BIND_MODES, DEPLOYMENT_EXPOSURES, DEPLOYMENT_MODES, SECRET_PROVIDERS, STORAGE_PROVIDERS, + inferBindModeFromHost, + resolveRuntimeBind, + type BindMode, type AuthBaseUrlMode, type DeploymentExposure, type DeploymentMode, @@ -23,6 +27,7 @@ import { promptLogging } from "../prompts/logging.js"; import { defaultSecretsConfig } from "../prompts/secrets.js"; import { defaultStorageConfig, promptStorage } from "../prompts/storage.js"; import { promptServer } from "../prompts/server.js"; +import { buildPresetServerConfig } from "../config/server-bind.js"; import { describeLocalInstancePaths, expandHomePrefix, @@ -46,10 +51,14 @@ type OnboardOptions = { run?: boolean; yes?: boolean; invokedByRun?: boolean; + bind?: BindMode; }; type OnboardDefaults = Pick; +const TAILNET_BIND_WARNING = + "No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set."; + const ONBOARD_ENV_KEYS = [ "PAPERCLIP_PUBLIC_URL", "DATABASE_URL", @@ -59,6 +68,9 @@ const ONBOARD_ENV_KEYS = [ "PAPERCLIP_DB_BACKUP_DIR", "PAPERCLIP_DEPLOYMENT_MODE", "PAPERCLIP_DEPLOYMENT_EXPOSURE", + "PAPERCLIP_BIND", + "PAPERCLIP_BIND_HOST", + "PAPERCLIP_TAILNET_BIND_HOST", "HOST", "PORT", "SERVE_UI", @@ -104,29 +116,62 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null { return path.resolve(expandHomePrefix(rawValue.trim())); } -function quickstartDefaultsFromEnv(): { +function describeServerBinding(server: Pick): string { + const bind = server.bind ?? inferBindModeFromHost(server.host); + const detail = + bind === "custom" + ? server.customBindHost ?? server.host + : bind === "tailnet" + ? "detected tailscale address" + : server.host; + return `${bind}${detail ? ` (${detail})` : ""}:${server.port}`; +} + +function quickstartDefaultsFromEnv(opts?: { preferTrustedLocal?: boolean }): { defaults: OnboardDefaults; usedEnvKeys: string[]; ignoredEnvKeys: Array<{ key: string; reason: string }>; } { + const preferTrustedLocal = opts?.preferTrustedLocal ?? false; const instanceId = resolvePaperclipInstanceId(); const defaultStorage = defaultStorageConfig(); const defaultSecrets = defaultSecretsConfig(); const databaseUrl = process.env.DATABASE_URL?.trim() || undefined; - const publicUrl = - process.env.PAPERCLIP_PUBLIC_URL?.trim() || - process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || - process.env.BETTER_AUTH_URL?.trim() || - process.env.BETTER_AUTH_BASE_URL?.trim() || - undefined; - const deploymentMode = - parseEnumFromEnv(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"; + const publicUrl = preferTrustedLocal + ? undefined + : ( + process.env.PAPERCLIP_PUBLIC_URL?.trim() || + process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL?.trim() || + process.env.BETTER_AUTH_URL?.trim() || + process.env.BETTER_AUTH_BASE_URL?.trim() || + undefined + ); + const deploymentMode = preferTrustedLocal + ? "local_trusted" + : (parseEnumFromEnv(process.env.PAPERCLIP_DEPLOYMENT_MODE, DEPLOYMENT_MODES) ?? "local_trusted"); const deploymentExposureFromEnv = parseEnumFromEnv( process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE, DEPLOYMENT_EXPOSURES, ); const deploymentExposure = deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? "private"); + const bindFromEnv = parseEnumFromEnv(process.env.PAPERCLIP_BIND, BIND_MODES); + const customBindHostFromEnv = process.env.PAPERCLIP_BIND_HOST?.trim() || undefined; + const hostFromEnv = process.env.HOST?.trim() || undefined; + const configuredBindHost = customBindHostFromEnv ?? hostFromEnv; + const bind = preferTrustedLocal + ? "loopback" + : ( + deploymentMode === "local_trusted" + ? "loopback" + : (bindFromEnv ?? (configuredBindHost ? inferBindModeFromHost(configuredBindHost) : "lan")) + ); + const resolvedBind = resolveRuntimeBind({ + bind, + host: hostFromEnv ?? (bind === "loopback" ? "127.0.0.1" : "0.0.0.0"), + customBindHost: customBindHostFromEnv, + tailnetBindHost: process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(), + }); const authPublicBaseUrl = publicUrl; const authBaseUrlModeFromEnv = parseEnumFromEnv( process.env.PAPERCLIP_AUTH_BASE_URL_MODE, @@ -183,7 +228,9 @@ function quickstartDefaultsFromEnv(): { server: { deploymentMode, exposure: deploymentExposure, - host: process.env.HOST ?? "127.0.0.1", + bind: resolvedBind.bind, + ...(resolvedBind.customBindHost ? { customBindHost: resolvedBind.customBindHost } : {}), + host: resolvedBind.host, port: Number(process.env.PORT) || 3100, allowedHostnames: Array.from(new Set([...allowedHostnamesFromEnv, ...(hostnameFromPublicUrl ? [hostnameFromPublicUrl] : [])])), serveUi: parseBooleanFromEnv(process.env.SERVE_UI) ?? true, @@ -220,12 +267,49 @@ function quickstartDefaultsFromEnv(): { }, }; const ignoredEnvKeys: Array<{ key: string; reason: string }> = []; + if (preferTrustedLocal) { + const forcedLocalReason = "Ignored because --yes quickstart forces trusted local loopback defaults"; + for (const key of [ + "PAPERCLIP_DEPLOYMENT_MODE", + "PAPERCLIP_DEPLOYMENT_EXPOSURE", + "PAPERCLIP_BIND", + "PAPERCLIP_BIND_HOST", + "HOST", + "PAPERCLIP_AUTH_BASE_URL_MODE", + "PAPERCLIP_AUTH_PUBLIC_BASE_URL", + "PAPERCLIP_PUBLIC_URL", + "BETTER_AUTH_URL", + "BETTER_AUTH_BASE_URL", + ] as const) { + if (process.env[key] !== undefined) { + ignoredEnvKeys.push({ key, reason: forcedLocalReason }); + } + } + } if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE !== undefined) { ignoredEnvKeys.push({ key: "PAPERCLIP_DEPLOYMENT_EXPOSURE", reason: "Ignored because deployment mode local_trusted always forces private exposure", }); } + if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND !== undefined) { + ignoredEnvKeys.push({ + key: "PAPERCLIP_BIND", + reason: "Ignored because deployment mode local_trusted always uses loopback reachability", + }); + } + if (deploymentMode === "local_trusted" && process.env.PAPERCLIP_BIND_HOST !== undefined) { + ignoredEnvKeys.push({ + key: "PAPERCLIP_BIND_HOST", + reason: "Ignored because deployment mode local_trusted always uses loopback reachability", + }); + } + if (deploymentMode === "local_trusted" && process.env.HOST !== undefined) { + ignoredEnvKeys.push({ + key: "HOST", + reason: "Ignored because deployment mode local_trusted always uses loopback reachability", + }); + } const ignoredKeySet = new Set(ignoredEnvKeys.map((entry) => entry.key)); const usedEnvKeys = ONBOARD_ENV_KEYS.filter( @@ -239,6 +323,10 @@ function canCreateBootstrapInviteImmediately(config: Pick { + if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) { + throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`); + } + printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai onboard "))); const configPath = resolveConfigPath(opts.config); @@ -293,7 +381,7 @@ export async function onboard(opts: OnboardOptions): Promise { `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}`, + `Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${describeServerBinding(existingConfig.server)}`, `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}`, @@ -336,7 +424,13 @@ export async function onboard(opts: OnboardOptions): Promise { let setupMode: SetupMode = "quickstart"; if (opts.yes) { - p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults.")); + p.log.message( + pc.dim( + opts.bind + ? `\`--yes\` enabled: using Quickstart defaults with bind=${opts.bind}.` + : "`--yes` enabled: using Quickstart defaults.", + ), + ); } else { const setupModeChoice = await p.select({ message: "Choose setup path", @@ -365,7 +459,9 @@ export async function onboard(opts: OnboardOptions): Promise { if (tc) trackInstallStarted(tc); let llm: PaperclipConfig["llm"] | undefined; - const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv(); + const { defaults: derivedDefaults, usedEnvKeys, ignoredEnvKeys } = quickstartDefaultsFromEnv({ + preferTrustedLocal: opts.yes === true && !opts.bind, + }); let { database, logging, @@ -375,6 +471,19 @@ export async function onboard(opts: OnboardOptions): Promise { secrets, } = derivedDefaults; + if (opts.bind === "loopback" || opts.bind === "lan" || opts.bind === "tailnet") { + const preset = buildPresetServerConfig(opts.bind, { + port: server.port, + allowedHostnames: server.allowedHostnames, + serveUi: server.serveUi, + }); + server = preset.server; + auth = preset.auth; + if (opts.bind === "tailnet" && server.host === "127.0.0.1") { + p.log.warn(TAILNET_BIND_WARNING); + } + } + if (setupMode === "advanced") { p.log.step(pc.bold("Database")); database = await promptDatabase(database); @@ -462,7 +571,13 @@ export async function onboard(opts: OnboardOptions): Promise { ); } else { p.log.step(pc.bold("Quickstart")); - p.log.message(pc.dim("Using quickstart defaults.")); + p.log.message( + pc.dim( + opts.bind + ? `Using quickstart defaults with bind=${opts.bind}.` + : `Using quickstart defaults: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}.`, + ), + ); if (usedEnvKeys.length > 0) { p.log.message(pc.dim(`Environment-aware defaults active (${usedEnvKeys.length} env var(s) detected).`)); } else { @@ -521,7 +636,7 @@ export async function onboard(opts: OnboardOptions): Promise { `Database: ${database.mode}`, llm ? `LLM: ${llm.provider}` : "LLM: not configured", `Logging: ${logging.mode} -> ${logging.logDir}`, - `Server: ${server.deploymentMode}/${server.exposure} @ ${server.host}:${server.port}`, + `Server: ${server.deploymentMode}/${server.exposure} @ ${describeServerBinding(server)}`, `Allowed hosts: ${server.allowedHostnames.length > 0 ? server.allowedHostnames.join(", ") : "(loopback only)"}`, `Auth URL mode: ${auth.baseUrlMode}${auth.publicBaseUrl ? ` (${auth.publicBaseUrl})` : ""}`, `Storage: ${storage.provider}`, diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 04743b482b1..9bc9655b448 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; import * as p from "@clack/prompts"; import pc from "picocolors"; @@ -21,6 +22,7 @@ interface RunOptions { instance?: string; repair?: boolean; yes?: boolean; + bind?: "loopback" | "lan" | "tailnet"; } interface StartedServer { @@ -57,7 +59,7 @@ export async function runCommand(opts: RunOptions): Promise { } p.log.step("No config found. Starting onboarding..."); - await onboard({ config: configPath, invokedByRun: true }); + await onboard({ config: configPath, invokedByRun: true, bind: opts.bind }); } p.log.step("Running doctor checks..."); @@ -146,11 +148,35 @@ function maybeEnableUiDevMiddleware(entrypoint: string): void { } } +function ensureDevWorkspaceBuildDeps(projectRoot: string): void { + const buildScript = path.resolve(projectRoot, "scripts/ensure-plugin-build-deps.mjs"); + if (!fs.existsSync(buildScript)) return; + + const result = spawnSync(process.execPath, [buildScript], { + cwd: projectRoot, + stdio: "inherit", + timeout: 120_000, + }); + + if (result.error) { + throw new Error( + `Failed to prepare workspace build artifacts before starting the Paperclip dev server.\n${formatError(result.error)}`, + ); + } + + if ((result.status ?? 1) !== 0) { + throw new Error( + "Failed to prepare workspace build artifacts before starting the Paperclip dev server.", + ); + } +} + async function importServerEntry(): Promise { // Dev mode: try local workspace path (monorepo with tsx) const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); const devEntry = path.resolve(projectRoot, "server/src/index.ts"); if (fs.existsSync(devEntry)) { + ensureDevWorkspaceBuildDeps(projectRoot); maybeEnableUiDevMiddleware(devEntry); const mod = await import(pathToFileURL(devEntry).href); return await startServerFromModule(mod, devEntry); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index d2b6c5f734d..5d4a0721192 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -214,6 +214,8 @@ export function buildWorktreeConfig(input: { server: { deploymentMode: source?.server.deploymentMode ?? "local_trusted", exposure: source?.server.exposure ?? "private", + ...(source?.server.bind ? { bind: source.server.bind } : {}), + ...(source?.server.customBindHost ? { customBindHost: source.server.customBindHost } : {}), host: source?.server.host ?? "127.0.0.1", port: serverPort, allowedHostnames: source?.server.allowedHostnames ?? [], diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 963ae5e82d3..be7b1ab7997 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -39,6 +39,8 @@ import { issues, projectWorkspaces, projects, + routines, + routineTriggers, runDatabaseBackup, runDatabaseRestore, createEmbeddedPostgresLogBuffer, @@ -922,6 +924,36 @@ async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): P }; } +export async function pauseSeededScheduledRoutines(connectionString: string): Promise { + const db = createDb(connectionString); + try { + const scheduledRoutineIds = await db + .selectDistinct({ routineId: routineTriggers.routineId }) + .from(routineTriggers) + .where(and(eq(routineTriggers.kind, "schedule"), eq(routineTriggers.enabled, true))); + const idsToPause = scheduledRoutineIds + .map((row) => row.routineId) + .filter((value): value is string => Boolean(value)); + + if (idsToPause.length === 0) { + return 0; + } + + const paused = await db + .update(routines) + .set({ + status: "paused", + updatedAt: new Date(), + }) + .where(and(inArray(routines.id, idsToPause), sql`${routines.status} <> 'paused'`, sql`${routines.status} <> 'archived'`)) + .returning({ id: routines.id }); + + return paused.length; + } finally { + await db.$client?.end?.({ timeout: 5 }).catch(() => undefined); + } +} + async function seedWorktreeDatabase(input: { sourceConfigPath: string; sourceConfig: PaperclipConfig; @@ -959,7 +991,7 @@ async function seedWorktreeDatabase(input: { const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, @@ -979,6 +1011,7 @@ async function seedWorktreeDatabase(input: { backupFile: backup.backupFile, }); await applyPendingMigrations(targetConnectionString); + await pauseSeededScheduledRoutines(targetConnectionString); const reboundWorkspaces = await rebindSeededProjectWorkspaces({ targetConnectionString, currentCwd: input.targetPaths.cwd, diff --git a/cli/src/config/server-bind.ts b/cli/src/config/server-bind.ts new file mode 100644 index 00000000000..3bcc80159e6 --- /dev/null +++ b/cli/src/config/server-bind.ts @@ -0,0 +1,183 @@ +import { execFileSync } from "node:child_process"; +import { + ALL_INTERFACES_BIND_HOST, + LOOPBACK_BIND_HOST, + inferBindModeFromHost, + isAllInterfacesHost, + isLoopbackHost, + type BindMode, + type DeploymentExposure, + type DeploymentMode, +} from "@paperclipai/shared"; +import type { AuthConfig, ServerConfig } from "./schema.js"; + +const TAILSCALE_DETECT_TIMEOUT_MS = 3000; + +type BaseServerInput = { + port: number; + allowedHostnames: string[]; + serveUi: boolean; +}; + +export function inferConfiguredBind(server?: Partial): BindMode { + if (server?.bind) return server.bind; + return inferBindModeFromHost(server?.customBindHost ?? server?.host); +} + +export function detectTailnetBindHost(): string | undefined { + const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(); + if (explicit) return explicit; + + try { + const stdout = execFileSync("tailscale", ["ip", "-4"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: TAILSCALE_DETECT_TIMEOUT_MS, + }); + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + } catch { + return undefined; + } +} + +export function buildPresetServerConfig( + bind: Exclude, + input: BaseServerInput, +): { server: ServerConfig; auth: AuthConfig } { + const host = + bind === "loopback" + ? LOOPBACK_BIND_HOST + : bind === "tailnet" + ? (detectTailnetBindHost() ?? LOOPBACK_BIND_HOST) + : ALL_INTERFACES_BIND_HOST; + + return { + server: { + deploymentMode: bind === "loopback" ? "local_trusted" : "authenticated", + exposure: "private", + bind, + customBindHost: undefined, + host, + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + }; +} + +export function buildCustomServerConfig(input: BaseServerInput & { + deploymentMode: DeploymentMode; + exposure: DeploymentExposure; + host: string; + publicBaseUrl?: string; +}): { server: ServerConfig; auth: AuthConfig } { + const normalizedHost = input.host.trim(); + const bind = isLoopbackHost(normalizedHost) + ? "loopback" + : isAllInterfacesHost(normalizedHost) + ? "lan" + : "custom"; + + return { + server: { + deploymentMode: input.deploymentMode, + exposure: input.deploymentMode === "local_trusted" ? "private" : input.exposure, + bind, + customBindHost: bind === "custom" ? normalizedHost : undefined, + host: normalizedHost, + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + }, + auth: + input.deploymentMode === "authenticated" && input.exposure === "public" + ? { + baseUrlMode: "explicit", + disableSignUp: false, + publicBaseUrl: input.publicBaseUrl, + } + : { + baseUrlMode: "auto", + disableSignUp: false, + }, + }; +} + +export function resolveQuickstartServerConfig(input: { + bind?: BindMode | null; + deploymentMode?: DeploymentMode | null; + exposure?: DeploymentExposure | null; + host?: string | null; + port: number; + allowedHostnames: string[]; + serveUi: boolean; + publicBaseUrl?: string; +}): { server: ServerConfig; auth: AuthConfig } { + const trimmedHost = input.host?.trim(); + const explicitBind = input.bind ?? null; + + if (explicitBind === "loopback" || explicitBind === "lan" || explicitBind === "tailnet") { + return buildPresetServerConfig(explicitBind, { + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + }); + } + + if (explicitBind === "custom") { + return buildCustomServerConfig({ + deploymentMode: input.deploymentMode ?? "authenticated", + exposure: input.exposure ?? "private", + host: trimmedHost || LOOPBACK_BIND_HOST, + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + publicBaseUrl: input.publicBaseUrl, + }); + } + + if (trimmedHost) { + return buildCustomServerConfig({ + deploymentMode: input.deploymentMode ?? (isLoopbackHost(trimmedHost) ? "local_trusted" : "authenticated"), + exposure: input.exposure ?? "private", + host: trimmedHost, + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + publicBaseUrl: input.publicBaseUrl, + }); + } + + if (input.deploymentMode === "authenticated") { + if (input.exposure === "public") { + return buildCustomServerConfig({ + deploymentMode: "authenticated", + exposure: "public", + host: ALL_INTERFACES_BIND_HOST, + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + publicBaseUrl: input.publicBaseUrl, + }); + } + + return buildPresetServerConfig("lan", { + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + }); + } + + return buildPresetServerConfig("loopback", { + port: input.port, + allowedHostnames: input.allowedHostnames, + serveUi: input.serveUi, + }); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 1173945961f..b2208798a73 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -50,7 +50,8 @@ program .description("Interactive first-run setup wizard") .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) - .option("-y, --yes", "Accept defaults (quickstart + start immediately)", false) + .option("--bind ", "Quickstart reachability preset (loopback, lan, tailnet)") + .option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false) .option("--run", "Start Paperclip immediately after saving config", false) .action(onboard); @@ -108,6 +109,7 @@ program .option("-c, --config ", "Path to config file") .option("-d, --data-dir ", DATA_DIR_OPTION_HELP) .option("-i, --instance ", "Local instance id (default: default)") + .option("--bind ", "On first run, use onboarding reachability preset (loopback, lan, tailnet)") .option("--repair", "Attempt automatic repairs during doctor", true) .option("--no-repair", "Disable automatic repairs during doctor") .action(runCommand); diff --git a/cli/src/prompts/server.ts b/cli/src/prompts/server.ts index e5c2618068a..404d4ea5752 100644 --- a/cli/src/prompts/server.ts +++ b/cli/src/prompts/server.ts @@ -1,6 +1,16 @@ import * as p from "@clack/prompts"; +import { isLoopbackHost, type BindMode } from "@paperclipai/shared"; import type { AuthConfig, ServerConfig } from "../config/schema.js"; import { parseHostnameCsv } from "../config/hostnames.js"; +import { buildCustomServerConfig, buildPresetServerConfig, inferConfiguredBind } from "../config/server-bind.js"; + +const TAILNET_BIND_WARNING = + "No Tailscale address was detected during setup. The saved config will stay on loopback until Tailscale is available or PAPERCLIP_TAILNET_BIND_HOST is set."; + +function cancelled(): never { + p.cancel("Setup cancelled."); + process.exit(0); +} export async function promptServer(opts?: { currentServer?: Partial; @@ -8,69 +18,37 @@ export async function promptServer(opts?: { }): Promise<{ server: ServerConfig; auth: AuthConfig }> { const currentServer = opts?.currentServer; const currentAuth = opts?.currentAuth; + const currentBind = inferConfiguredBind(currentServer); - const deploymentModeSelection = await p.select({ - message: "Deployment mode", + const bindSelection = await p.select({ + message: "Reachability", options: [ { - value: "local_trusted", - label: "Local trusted", - hint: "Easiest for local setup (no login, localhost-only)", + value: "loopback" as const, + label: "Trusted local", + hint: "Recommended for first run: localhost only, no login friction", }, { - value: "authenticated", - label: "Authenticated", - hint: "Login required; use for private network or public hosting", + value: "lan" as const, + label: "Private network", + hint: "Broad private bind for LAN, VPN, or legacy --tailscale-auth style access", + }, + { + value: "tailnet" as const, + label: "Tailnet", + hint: "Private authenticated access using the machine's detected Tailscale address", + }, + { + value: "custom" as const, + label: "Custom", + hint: "Choose exact auth mode, exposure, and host manually", }, ], - initialValue: currentServer?.deploymentMode ?? "local_trusted", + initialValue: currentBind, }); - if (p.isCancel(deploymentModeSelection)) { - p.cancel("Setup cancelled."); - process.exit(0); - } - const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"]; - - let exposure: ServerConfig["exposure"] = "private"; - if (deploymentMode === "authenticated") { - const exposureSelection = await p.select({ - message: "Exposure profile", - options: [ - { - value: "private", - label: "Private network", - hint: "Private access (for example Tailscale), lower setup friction", - }, - { - value: "public", - label: "Public internet", - hint: "Internet-facing deployment with stricter requirements", - }, - ], - initialValue: currentServer?.exposure ?? "private", - }); - if (p.isCancel(exposureSelection)) { - p.cancel("Setup cancelled."); - process.exit(0); - } - exposure = exposureSelection as ServerConfig["exposure"]; - } - - const hostDefault = deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0"; - const hostStr = await p.text({ - message: "Bind host", - defaultValue: currentServer?.host ?? hostDefault, - placeholder: hostDefault, - validate: (val) => { - if (!val.trim()) return "Host is required"; - }, - }); - - if (p.isCancel(hostStr)) { - p.cancel("Setup cancelled."); - process.exit(0); - } + if (p.isCancel(bindSelection)) cancelled(); + const bind = bindSelection as BindMode; const portStr = await p.text({ message: "Server port", @@ -84,15 +62,113 @@ export async function promptServer(opts?: { }, }); - if (p.isCancel(portStr)) { - p.cancel("Setup cancelled."); - process.exit(0); + if (p.isCancel(portStr)) cancelled(); + const port = Number(portStr) || 3100; + const serveUi = currentServer?.serveUi ?? true; + + if (bind === "loopback") { + return buildPresetServerConfig("loopback", { + port, + allowedHostnames: [], + serveUi, + }); } + if (bind === "lan" || bind === "tailnet") { + const allowedHostnamesInput = await p.text({ + message: "Allowed private hostnames (comma-separated, optional)", + defaultValue: (currentServer?.allowedHostnames ?? []).join(", "), + placeholder: + bind === "tailnet" + ? "your-machine.tailnet.ts.net" + : "dotta-macbook-pro, host.docker.internal", + validate: (val) => { + try { + parseHostnameCsv(val); + return; + } catch (err) { + return err instanceof Error ? err.message : "Invalid hostname list"; + } + }, + }); + + if (p.isCancel(allowedHostnamesInput)) cancelled(); + + const preset = buildPresetServerConfig(bind, { + port, + allowedHostnames: parseHostnameCsv(allowedHostnamesInput), + serveUi, + }); + if (bind === "tailnet" && isLoopbackHost(preset.server.host)) { + p.log.warn(TAILNET_BIND_WARNING); + } + return preset; + } + + const deploymentModeSelection = await p.select({ + message: "Auth mode", + options: [ + { + value: "local_trusted", + label: "Local trusted", + hint: "No login required; only safe with loopback-only or similarly trusted access", + }, + { + value: "authenticated", + label: "Authenticated", + hint: "Login required; supports both private-network and public deployments", + }, + ], + initialValue: currentServer?.deploymentMode ?? "authenticated", + }); + + if (p.isCancel(deploymentModeSelection)) cancelled(); + const deploymentMode = deploymentModeSelection as ServerConfig["deploymentMode"]; + + let exposure: ServerConfig["exposure"] = "private"; + if (deploymentMode === "authenticated") { + const exposureSelection = await p.select({ + message: "Exposure profile", + options: [ + { + value: "private", + label: "Private network", + hint: "Private access only, with automatic URL handling", + }, + { + value: "public", + label: "Public internet", + hint: "Internet-facing deployment with explicit public URL requirements", + }, + ], + initialValue: currentServer?.exposure ?? "private", + }); + if (p.isCancel(exposureSelection)) cancelled(); + exposure = exposureSelection as ServerConfig["exposure"]; + } + + const defaultHost = + currentServer?.customBindHost ?? + currentServer?.host ?? + (deploymentMode === "local_trusted" ? "127.0.0.1" : "0.0.0.0"); + const host = await p.text({ + message: "Bind host", + defaultValue: defaultHost, + placeholder: defaultHost, + validate: (val) => { + if (!val.trim()) return "Host is required"; + if (deploymentMode === "local_trusted" && !isLoopbackHost(val.trim())) { + return "Local trusted mode requires a loopback host such as 127.0.0.1"; + } + }, + }); + + if (p.isCancel(host)) cancelled(); + let allowedHostnames: string[] = []; if (deploymentMode === "authenticated" && exposure === "private") { const allowedHostnamesInput = await p.text({ - message: "Allowed hostnames (comma-separated, optional)", + message: "Allowed private hostnames (comma-separated, optional)", defaultValue: (currentServer?.allowedHostnames ?? []).join(", "), placeholder: "dotta-macbook-pro, your-host.tailnet.ts.net", validate: (val) => { @@ -105,15 +181,11 @@ export async function promptServer(opts?: { }, }); - if (p.isCancel(allowedHostnamesInput)) { - p.cancel("Setup cancelled."); - process.exit(0); - } + if (p.isCancel(allowedHostnamesInput)) cancelled(); allowedHostnames = parseHostnameCsv(allowedHostnamesInput); } - const port = Number(portStr) || 3100; - let auth: AuthConfig = { baseUrlMode: "auto", disableSignUp: false }; + let publicBaseUrl: string | undefined; if (deploymentMode === "authenticated" && exposure === "public") { const urlInput = await p.text({ message: "Public base URL", @@ -133,32 +205,17 @@ export async function promptServer(opts?: { } }, }); - if (p.isCancel(urlInput)) { - p.cancel("Setup cancelled."); - process.exit(0); - } - auth = { - baseUrlMode: "explicit", - disableSignUp: false, - publicBaseUrl: urlInput.trim().replace(/\/+$/, ""), - }; - } else if (currentAuth?.baseUrlMode === "explicit" && currentAuth.publicBaseUrl) { - auth = { - baseUrlMode: "explicit", - disableSignUp: false, - publicBaseUrl: currentAuth.publicBaseUrl, - }; + if (p.isCancel(urlInput)) cancelled(); + publicBaseUrl = urlInput.trim().replace(/\/+$/, ""); } - return { - server: { - deploymentMode, - exposure, - host: hostStr.trim(), - port, - allowedHostnames, - serveUi: currentServer?.serveUi ?? true, - }, - auth, - }; + return buildCustomServerConfig({ + deploymentMode, + exposure, + host: host.trim(), + port, + allowedHostnames, + serveUi, + publicBaseUrl, + }); } diff --git a/doc/CLI.md b/doc/CLI.md index 6f945656d4d..c124b44745d 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -32,10 +32,12 @@ Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`. Current CLI behavior: - `paperclipai onboard` and `paperclipai configure --section server` set deployment mode in config +- server onboarding/configure ask for reachability intent and write `server.bind` +- `paperclipai run --bind ` passes a quickstart bind preset into first-run onboarding when config is missing - runtime can override mode with `PAPERCLIP_DEPLOYMENT_MODE` -- `paperclipai run` and `paperclipai doctor` do not yet expose a direct `--mode` flag +- `paperclipai run` and `paperclipai doctor` still do not expose a direct low-level `--mode` flag -Target behavior (planned) is documented in `doc/DEPLOYMENT-MODES.md` section 5. +Canonical behavior is documented in `doc/DEPLOYMENT-MODES.md`. Allow an authenticated/private hostname (for example custom Tailscale DNS): diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index 9d58d50a747..a7b8d7fb0a4 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -17,6 +17,11 @@ Paperclip supports two runtime modes: This keeps one authenticated auth stack while still separating low-friction private-network defaults from internet-facing hardening requirements. +Paperclip now treats **bind** as a separate concern from auth: + +- auth model: `local_trusted` vs `authenticated`, plus `private/public` +- reachability model: `server.bind = loopback | lan | tailnet | custom` + ## 2. Canonical Model | Runtime Mode | Exposure | Human auth | Primary use | @@ -25,6 +30,15 @@ This keeps one authenticated auth stack while still separating low-friction priv | `authenticated` | `private` | Login required | Private-network access (for example Tailscale/VPN/LAN) | | `authenticated` | `public` | Login required | Internet-facing/cloud deployment | +## Reachability Model + +| Bind | Meaning | Typical use | +|---|---|---| +| `loopback` | Listen on localhost only | default local usage, reverse-proxy deployments | +| `lan` | Listen on all interfaces (`0.0.0.0`) | LAN/VPN/private-network access | +| `tailnet` | Listen on a detected Tailscale IP | Tailscale-only access | +| `custom` | Listen on an explicit host/IP | advanced interface-specific setups | + ## 3. Security Policy ## `local_trusted` @@ -38,12 +52,14 @@ This keeps one authenticated auth stack while still separating low-friction priv - login required - low-friction URL handling (`auto` base URL mode) - private-host trust policy required +- bind can be `loopback`, `lan`, `tailnet`, or `custom` ## `authenticated + public` - login required - explicit public URL required - stricter deployment checks and failures in doctor +- recommended bind is `loopback` behind a reverse proxy; direct `lan/custom` is advanced ## 4. Onboarding UX Contract @@ -55,14 +71,22 @@ pnpm paperclipai onboard Server prompt behavior: -1. ask mode, default `local_trusted` -2. option copy: -- `local_trusted`: "Easiest for local setup (no login, localhost-only)" -- `authenticated`: "Login required; use for private network or public hosting" -3. if `authenticated`, ask exposure: -- `private`: "Private network access (for example Tailscale), lower setup friction" -- `public`: "Internet-facing deployment, stricter security requirements" -4. ask explicit public URL only for `authenticated + public` +1. quickstart `--yes` defaults to `server.bind=loopback` and therefore `local_trusted/private` +2. advanced server setup asks reachability first: +- `Trusted local` → `bind=loopback`, `local_trusted/private` +- `Private network` → `bind=lan`, `authenticated/private` +- `Tailnet` → `bind=tailnet`, `authenticated/private` +- `Custom` → manual mode/exposure/host entry +3. raw host entry is only required for the `Custom` path +4. explicit public URL is only required for `authenticated + public` + +Examples: + +```sh +pnpm paperclipai onboard --yes +pnpm paperclipai onboard --yes --bind lan +pnpm paperclipai run --bind tailnet +``` `configure --section server` follows the same interactive behavior. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 724496e9e9b..f86ab47be82 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -55,10 +55,23 @@ pnpm dev:stop Tailscale/private-auth dev mode: ```sh -pnpm dev --tailscale-auth +pnpm dev --bind lan ``` -This runs dev as `authenticated/private` and binds the server to `0.0.0.0` for private-network access. +This runs dev as `authenticated/private` with a private-network bind preset. + +For Tailscale-only reachability on a detected tailnet address: + +```sh +pnpm dev --bind tailnet +``` + +Legacy aliases still map to the old broad private-network behavior: + +```sh +pnpm dev --tailscale-auth +pnpm dev --authenticated-private +``` Allow additional private hostnames (for example custom Tailscale hostnames): @@ -175,7 +188,9 @@ Seed modes: After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. -Provisioned git worktrees also pause all seeded routines in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development. +`pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first. + +Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines. That repo-local env also sets: diff --git a/doc/OPENCLAW_ONBOARDING.md b/doc/OPENCLAW_ONBOARDING.md index bdb098b371b..32f27ea9890 100644 --- a/doc/OPENCLAW_ONBOARDING.md +++ b/doc/OPENCLAW_ONBOARDING.md @@ -3,7 +3,7 @@ Use this exact checklist. 1. Start Paperclip in auth mode. ```bash cd -pnpm dev --tailscale-auth +pnpm dev --bind lan ``` Then verify: ```bash diff --git a/doc/memory-landscape.md b/doc/memory-landscape.md index 9648828c456..f57f0f736af 100644 --- a/doc/memory-landscape.md +++ b/doc/memory-landscape.md @@ -22,6 +22,7 @@ The question is not "which memory project wins?" The question is "what is the sm ### Hosted memory APIs - `mem0` +- `AWS Bedrock AgentCore Memory` - `supermemory` - `Memori` @@ -49,6 +50,7 @@ These emphasize local persistence, inspectability, and low operational overhead. |---|---|---|---|---| | [nuggets](https://github.com/NeoVertex1/nuggets) | local memory engine + messaging gateway | topic-scoped HRR memory with `remember`, `recall`, `forget`, fact promotion into `MEMORY.md` | good example of lightweight local memory and automatic promotion | very specific architecture; not a general multi-tenant service | | [mem0](https://github.com/mem0ai/mem0) | hosted + OSS SDK | `add`, `search`, `getAll`, `get`, `update`, `delete`, `deleteAll`; entity partitioning via `user_id`, `agent_id`, `run_id`, `app_id` | closest to a clean provider API with identities and metadata filters | provider owns extraction heavily; Paperclip should not assume every backend behaves like mem0 | +| [AWS Bedrock AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html) | AWS-managed memory service | explicit short-term and long-term memories, actor/session/event APIs, memory strategies, namespace templates, optional self-managed extraction pipeline | strong example of provider-managed memory with clear scoped ids, retention controls, and standalone API access outside a single agent framework | AWS-hosted and IAM-centric; Paperclip would still need its own company/run/comment provenance, cost rollups, and likely a plugin wrapper instead of baking AWS semantics into core | | [MemOS](https://github.com/MemTensor/MemOS) | memory OS / framework | unified add-retrieve-edit-delete, memory cubes, multimodal memory, tool memory, async scheduler, feedback/correction | strong source for optional capabilities beyond plain search | much broader than the minimal contract Paperclip should standardize first | | [supermemory](https://github.com/supermemoryai/supermemory) | hosted memory + context API | `add`, `profile`, `search.memories`, `search.documents`, document upload, settings; automatic profile building and forgetting | strong example of "context bundle" rather than raw search results | heavily productized around its own ontology and hosted flow | | [memU](https://github.com/NevaMind-AI/memU) | proactive agent memory framework | file-system metaphor, proactive loop, intent prediction, always-on companion model | good source for when memory should trigger agent behavior, not just retrieval | proactive assistant framing is broader than Paperclip's task-centric control plane | @@ -77,6 +79,7 @@ These differences are exactly why Paperclip needs a layered contract instead of ### 1. Who owns extraction? - `mem0`, `supermemory`, and `Memori` expect the provider to infer memories from conversations. +- `AWS Bedrock AgentCore Memory` supports both provider-managed extraction and self-managed pipelines where the host writes curated long-term memory records. - `memsearch` expects the host to decide what markdown to write, then indexes it. - `MemOS`, `memU`, `EverMemOS`, and `OpenViking` sit somewhere in between and often expose richer memory construction pipelines. @@ -104,6 +107,7 @@ Paperclip should make plain search the minimum contract and richer outputs optio ### 4. Is memory synchronous or asynchronous? - local tools often work synchronously in-process. +- `AWS Bedrock AgentCore Memory` is synchronous at the API edge, but its long-term memory path includes background extraction/indexing behavior and retention policies managed by the provider. - larger systems add schedulers, background indexing, compaction, or sync jobs. Paperclip needs both direct request/response operations and background maintenance hooks. diff --git a/doc/plans/2026-03-17-memory-service-surface-api.md b/doc/plans/2026-03-17-memory-service-surface-api.md index 68b33bd3f55..50ae47e14c3 100644 --- a/doc/plans/2026-03-17-memory-service-surface-api.md +++ b/doc/plans/2026-03-17-memory-service-surface-api.md @@ -7,10 +7,10 @@ Define a Paperclip memory service and surface API that can sit above multiple me - company scoping - auditability - provenance back to Paperclip work objects -- budget / cost visibility +- budget and cost visibility - plugin-first extensibility -This plan is based on the external landscape summarized in `doc/memory-landscape.md` and on the current Paperclip architecture in: +This plan is based on the external landscape summarized in `doc/memory-landscape.md`, the AWS AgentCore comparison captured in [PAP-1274](/PAP/issues/PAP-1274), and the current Paperclip architecture in: - `doc/SPEC-implementation.md` - `doc/plugins/PLUGIN_SPEC.md` @@ -19,23 +19,26 @@ This plan is based on the external landscape summarized in `doc/memory-landscape ## Recommendation In One Sentence -Paperclip should not embed one opinionated memory engine into core. It should add a company-scoped memory control plane with a small normalized adapter contract, then let built-ins and plugins implement the provider-specific behavior. +Paperclip should add a company-scoped memory control plane with company default plus agent override resolution, shared hook delivery, and full operation attribution, while leaving extraction and storage semantics to built-ins and plugins. ## Product Decisions -### 1. Memory is company-scoped by default +### 1. Memory resolution is company default plus agent override Every memory binding belongs to exactly one company. -That binding can then be: +Resolution order in V1: -- the company default -- an agent override -- a project override later if we need it +- company default binding +- optional per-agent override + +There is no per-project override in V1. + +Project context can still appear in scope and provenance so providers can use it for retrieval and partitioning, but projects do not participate in binding selection. No cross-company memory sharing in the initial design. -### 2. Providers are selected by key +### 2. Providers are selected by stable binding key Each configured memory provider gets a stable key inside a company, for example: @@ -44,36 +47,53 @@ Each configured memory provider gets a stable key inside a company, for example: - `local-markdown` - `research-kb` -Agents and services resolve the active provider by key, not by hard-coded vendor logic. +Agents, tools, and background hooks resolve the active provider by key, not by hard-coded vendor logic. ### 3. Plugins are the primary provider path Built-ins are useful for a zero-config local path, but most providers should arrive through the existing Paperclip plugin runtime. -That keeps the core small and matches the current direction that optional knowledge-like systems live at the edges. +That keeps the core small and matches the broader Paperclip direction that specialized knowledge systems live at the edges. -### 4. Paperclip owns routing, provenance, and accounting +### 4. Paperclip owns routing, provenance, and policy Providers should not decide how Paperclip entities map to governance. Paperclip core should own: +- binding resolution - who is allowed to call a memory operation -- which company / agent / project scope is active -- what issue / run / comment / document the operation belongs to -- how usage gets recorded +- which company, agent, issue, project, run, and subject scope is active +- what source object the operation belongs to +- how usage and costs are attributed +- how operators inspect what happened -### 5. Automatic memory should be narrow at first +### 5. Paperclip exposes shared hooks, providers own extraction + +Paperclip should emit a common set of memory hooks that built-ins, third-party adapters, and plugins can all use. + +Those hooks should pass structured Paperclip source objects plus normalized metadata. The provider then decides how to extract from those objects. + +Paperclip should not force one extraction pipeline or one canonical "memory text" transform before the provider sees the input. + +### 6. Automatic memory should start narrow, but the hook surface should be general Automatic capture is useful, but broad silent capture is dangerous. -Initial automatic hooks should be: +Initial built-in automatic hooks should be: +- pre-run hydrate for agent context recall - post-run capture from agent runs -- issue comment / document capture when the binding enables it -- pre-run recall for agent context hydration +- optional issue comment capture +- optional issue document capture -Everything else should start explicit. +The hook registry itself should be general enough that other providers can subscribe to the same events without core changes. + +### 7. No approval gate for binding changes in the open-source product + +For the open-source version, changing memory bindings should not require approvals. + +Paperclip should still log those changes in activity and preserve full auditability. Approval-gated memory governance can remain an enterprise or future policy layer. ## Proposed Concepts @@ -83,7 +103,7 @@ A built-in or plugin-supplied implementation that stores and retrieves memory. Examples: -- local markdown + vector index +- local markdown plus semantic index - mem0 adapter - supermemory adapter - MemOS adapter @@ -94,6 +114,15 @@ A company-scoped configuration record that points to a provider and carries prov This is the object selected by key. +### Memory binding target + +A mapping from a Paperclip target to a binding. + +V1 targets: + +- `company` +- `agent` + ### Memory scope The normalized Paperclip scope passed into a provider request. @@ -105,7 +134,9 @@ At minimum: - optional `projectId` - optional `issueId` - optional `runId` -- optional `subjectId` for external/user identity +- optional `subjectId` for external or user identity +- optional `sessionKey` for providers that organize memory around sessions +- optional `namespace` for providers that need an explicit partition hint ### Memory source reference @@ -121,24 +152,36 @@ Supported source kinds should include: - `manual_note` - `external_document` +### Memory hook + +A normalized trigger emitted by Paperclip when something memory-relevant happens. + +Initial hook kinds: + +- `pre_run_hydrate` +- `post_run_capture` +- `issue_comment_capture` +- `issue_document_capture` +- `manual_capture` + ### Memory operation -A normalized write, query, browse, or delete action performed through Paperclip. +A normalized capture, record-write, query, browse, get, correction, or delete action performed through Paperclip. -Paperclip should log every operation, whether the provider is local or external. +Paperclip should log every memory operation whether the provider is local, plugin-backed, or external. ## Required Adapter Contract -The required core should be small enough to fit `memsearch`, `mem0`, `Memori`, `MemOS`, or `OpenViking`. +The required core should be small enough to fit `memsearch`, `mem0`, `Memori`, `MemOS`, or `OpenViking`, but strong enough to satisfy Paperclip's attribution and inspectability requirements. ```ts export interface MemoryAdapterCapabilities { profile?: boolean; - browse?: boolean; correction?: boolean; - asyncIngestion?: boolean; multimodal?: boolean; providerManagedExtraction?: boolean; + asyncExtraction?: boolean; + providerNativeBrowse?: boolean; } export interface MemoryScope { @@ -148,6 +191,8 @@ export interface MemoryScope { issueId?: string; runId?: string; subjectId?: string; + sessionKey?: string; + namespace?: string; } export interface MemorySourceRef { @@ -168,10 +213,34 @@ export interface MemorySourceRef { externalRef?: string; } +export interface MemoryHookContext { + hookKind: + | "pre_run_hydrate" + | "post_run_capture" + | "issue_comment_capture" + | "issue_document_capture" + | "manual_capture"; + hookId: string; + triggeredAt: string; + actorAgentId?: string; + heartbeatRunId?: string; +} + +export interface MemorySourcePayload { + text?: string; + mimeType?: string; + metadata?: Record; + object?: Record; +} + export interface MemoryUsage { provider: string; + biller?: string; model?: string; + billingType?: "metered_api" | "subscription_included" | "subscription_overage" | "unknown"; + attributionMode?: "billed_directly" | "included_in_run" | "external_invoice" | "untracked"; inputTokens?: number; + cachedInputTokens?: number; outputTokens?: number; embeddingTokens?: number; costCents?: number; @@ -179,20 +248,32 @@ export interface MemoryUsage { details?: Record; } -export interface MemoryWriteRequest { - bindingKey: string; - scope: MemoryScope; - source: MemorySourceRef; - content: string; - metadata?: Record; - mode?: "append" | "upsert" | "summarize"; -} - export interface MemoryRecordHandle { providerKey: string; providerRecordId: string; } +export interface MemoryCaptureRequest { + bindingKey: string; + scope: MemoryScope; + source: MemorySourceRef; + payload: MemorySourcePayload; + hook?: MemoryHookContext; + mode?: "capture_residue" | "capture_record"; + metadata?: Record; +} + +export interface MemoryRecordWriteRequest { + bindingKey: string; + scope: MemoryScope; + source?: MemorySourceRef; + records: Array<{ + text: string; + summary?: string; + metadata?: Record; + }>; +} + export interface MemoryQueryRequest { bindingKey: string; scope: MemoryScope; @@ -202,6 +283,14 @@ export interface MemoryQueryRequest { metadataFilter?: Record; } +export interface MemoryListRequest { + bindingKey: string; + scope: MemoryScope; + cursor?: string; + limit?: number; + metadataFilter?: Record; +} + export interface MemorySnippet { handle: MemoryRecordHandle; text: string; @@ -217,30 +306,149 @@ export interface MemoryContextBundle { usage?: MemoryUsage[]; } +export interface MemoryListPage { + items: MemorySnippet[]; + nextCursor?: string; + usage?: MemoryUsage[]; +} + +export interface MemoryExtractionJob { + providerJobId: string; + status: "queued" | "running" | "succeeded" | "failed" | "cancelled"; + hookKind?: MemoryHookContext["hookKind"]; + source?: MemorySourceRef; + error?: string; + submittedAt?: string; + startedAt?: string; + finishedAt?: string; +} + export interface MemoryAdapter { key: string; capabilities: MemoryAdapterCapabilities; - write(req: MemoryWriteRequest): Promise<{ + capture(req: MemoryCaptureRequest): Promise<{ + records?: MemoryRecordHandle[]; + jobs?: MemoryExtractionJob[]; + usage?: MemoryUsage[]; + }>; + upsertRecords(req: MemoryRecordWriteRequest): Promise<{ records?: MemoryRecordHandle[]; usage?: MemoryUsage[]; }>; query(req: MemoryQueryRequest): Promise; + list(req: MemoryListRequest): Promise; get(handle: MemoryRecordHandle, scope: MemoryScope): Promise; forget(handles: MemoryRecordHandle[], scope: MemoryScope): Promise<{ usage?: MemoryUsage[] }>; } ``` -This contract intentionally does not force a provider to expose its internal graph, filesystem, or ontology. +This contract intentionally does not force a provider to expose its internal graph, file tree, or ontology. It does require enough structure for Paperclip to browse, attribute, and audit what happened. ## Optional Adapter Surfaces These should be capability-gated, not required: -- `browse(scope, filters)` for file-system / graph / timeline inspection - `correct(handle, patch)` for natural-language correction flows - `profile(scope)` when the provider can synthesize stable preferences or summaries -- `sync(source)` for connectors or background ingestion +- `listExtractionJobs(scope, cursor)` when async extraction needs richer operator visibility +- `retryExtractionJob(jobId)` when a provider supports re-drive - `explain(queryResult)` for providers that can expose retrieval traces +- provider-native browse or graph surfaces exposed through plugin UI + +## Lessons From AWS AgentCore Memory API + +AWS AgentCore Memory is a useful check on whether this plan is too abstract or missing important operational surfaces. + +The broad direction still looks right: + +- AWS splits memory into a control plane (`CreateMemory`, `UpdateMemory`, `ListMemories`) and a data plane (`CreateEvent`, `RetrieveMemoryRecords`, `GetMemoryRecord`, `ListMemoryRecords`) +- AWS separates raw interaction capture from curated long-term memory records +- AWS supports both provider-managed extraction and self-managed pipelines +- AWS treats browse and list operations as first-class APIs, not ad hoc debugging helpers +- AWS exposes extraction jobs instead of hiding asynchronous maintenance completely + +That lines up with the Paperclip plan at a high level: provider configuration, scoped writes, scoped retrieval, provider-managed extraction as a capability, and a browse and inspect surface. + +The concrete changes Paperclip should take from AWS are: + +### 1. Keep config APIs separate from runtime traffic + +The rollout should preserve a clean separation between: + +- control-plane APIs for binding CRUD, defaults, overrides, and capability metadata +- runtime APIs and tools for capture, record writes, query, list, get, forget, and extraction status + +This keeps governance changes distinct from high-volume memory traffic. + +### 2. Distinguish capture from curated record writes + +AWS does not flatten everything into one write primitive. It distinguishes captured events from durable memory records. + +Paperclip should do the same: + +- `capture(...)` for raw run, comment, document, or activity residue +- `upsertRecords(...)` for curated durable facts and notes + +That is a better fit for provider-managed extraction and for manual curation flows. + +### 3. Make list and browse first-class + +AWS exposes list and retrieve surfaces directly. Paperclip should not make browse optional at the portable layer. + +The minimum portable surface should include: + +- `query` +- `list` +- `get` + +Provider-native graph or file browsing can remain optional beyond that. + +### 4. Add pagination and cursors for operator inspection + +AWS consistently uses pagination on browse-heavy APIs. + +Paperclip should add cursor-based pagination to: + +- record listing +- extraction job listing +- memory operation explorer APIs + +Prompt hydration can continue to use `topK`, but operator surfaces need cursors. + +### 5. Add explicit session and namespace hints + +AWS uses `actorId`, `sessionId`, `namespace`, and `memoryStrategyId` heavily. + +Paperclip should keep its own control-plane-centric model, but the adapter contract needs obvious places to map those concepts: + +- `sessionKey` +- `namespace` + +The provider adapter can map them to AWS or other vendor-specific identifiers without leaking those identifiers into core. + +### 6. Treat asynchronous extraction as a real operational surface + +AWS exposes extraction jobs explicitly. Paperclip should too. + +Operators should be able to see: + +- pending extraction work +- failed extraction work +- which hook or source caused the work +- whether a retry is available + +### 7. Keep Paperclip provenance primary + +Paperclip should continue to center: + +- `companyId` +- `agentId` +- `projectId` +- `issueId` +- `runId` +- issue comments, documents, and activity as sources + +The lesson from AWS is to support clean mapping into provider-specific models, not to let provider identifiers take over the core product model. ## What Paperclip Should Persist @@ -248,39 +456,67 @@ Paperclip should not mirror the full provider memory corpus into Postgres unless Paperclip core should persist: -- memory bindings and overrides +- memory bindings +- company default and agent override resolution targets - provider keys and capability metadata - normalized memory operation logs -- provider record handles returned by operations when available - source references back to issue comments, documents, runs, and activity -- usage and cost data +- provider record handles returned by operations when available +- hook delivery records and extraction job state +- usage and cost attribution -For external providers, the memory payload itself can remain in the provider. +For external providers, the actual memory payload can remain in the provider. ## Hook Model -### Automatic hooks +### Shared hook surface + +Paperclip should expose one shared hook system for memory. + +That same system must be available to: + +- built-in memory providers +- plugin-based memory providers +- third-party adapter integrations that want to use memory hooks + +### What a hook delivers + +Each hook delivery should include: + +- resolved binding key +- normalized `MemoryScope` +- `MemorySourceRef` +- structured source payload +- hook metadata such as hook kind, trigger time, and related run id + +The payload should include structured objects where possible so the provider can decide how to extract and chunk. + +### Initial automatic hooks These should be low-risk and easy to reason about: -1. `pre-run hydrate` +1. `pre_run_hydrate` Before an agent run starts, Paperclip may call `query(... intent = "agent_preamble")` using the active binding. -2. `post-run capture` - After a run finishes, Paperclip may write a summary or transcript-derived note tied to the run. +2. `post_run_capture` + After a run finishes, Paperclip may call `capture(...)` with structured run output, excerpts, and provenance. -3. `issue comment / document capture` - When enabled on the binding, Paperclip may capture selected issue comments or issue documents as memory sources. +3. `issue_comment_capture` + When enabled on the binding, Paperclip may call `capture(...)` for selected issue comments. -### Explicit hooks +4. `issue_document_capture` + When enabled on the binding, Paperclip may call `capture(...)` for selected issue documents. -These should be tool- or UI-driven first: +### Explicit tools and APIs + +These should be tool-driven or UI-driven first: - `memory.search` - `memory.note` - `memory.forget` - `memory.correct` -- `memory.browse` +- memory record list and get +- extraction-job inspection ### Not automatic in the first version @@ -309,34 +545,69 @@ The initial browse surface should support: - active binding by company and agent - recent memory operations -- recent write sources +- recent write and capture sources +- record list and record detail with source backlinks - query results with source backlinks -- filters by agent, issue, run, source kind, and date -- provider usage / cost / latency summaries +- extraction job status +- filters by agent, issue, project, run, source kind, and date +- provider usage, cost, and latency summaries When a provider supports richer browsing, the plugin can add deeper views through the existing plugin UI surfaces. ## Cost And Evaluation -Every adapter response should be able to return usage records. +Paperclip should treat memory accounting as two related but distinct concerns: -Paperclip should roll up: +### 1. `memory_operations` is the authoritative audit trail -- memory inference tokens -- embedding tokens -- external provider cost +Every memory action should create a normalized operation record that captures: + +- binding +- scope +- source provenance +- operation type +- success or failure - latency -- query count -- write count +- usage details reported by the provider +- attribution mode +- related run, issue, and agent when available -It should also record evaluation-oriented metrics where possible: +This is where operators answer "what memory work happened and why?" + +### 2. `cost_events` remains the canonical spend ledger for billable metered usage + +The current `cost_events` model is already the canonical cost ledger for token and model spend, and `agent_runtime_state` plus `heartbeat_runs.usageJson` already roll up and summarize run usage. + +The recommendation is: + +- if a memory operation runs inside a normal Paperclip agent heartbeat and the model usage is already counted on that run, do not create a duplicate `cost_event` +- instead, store the memory operation with `attributionMode = "included_in_run"` and link it to the related `heartbeatRunId` +- if a memory provider makes a direct metered model call outside the agent run accounting path, the provider must report usage and Paperclip should create a `cost_event` +- that direct `cost_event` should still link back to the memory operation, agent, company, and issue or run context when possible + +### 3. `finance_events` should carry flat subscription or invoice-style costs + +If a memory service incurs: + +- monthly subscription cost +- storage invoices +- provider platform charges not tied to one request + +those should be represented as `finance_events`, not as synthetic per-query memory operations. + +That keeps usage telemetry separate from accounting entries like invoices and flat fees. + +### 4. Evaluation metrics still matter + +Paperclip should record evaluation-oriented metrics where possible: - recall hit rate - empty query rate - manual correction count -- per-binding success / failure counts +- extraction failure count +- per-binding success and failure counts -This is important because a memory system that "works" but silently burns budget is not acceptable in Paperclip. +This is important because a memory system that "works" but silently burns budget or silently fails extraction is not acceptable in Paperclip. ## Suggested Data Model Additions @@ -344,23 +615,36 @@ At the control-plane level, the likely new core tables are: - `memory_bindings` - company-scoped key - - provider id / plugin id + - provider id or plugin id - config blob - enabled status - `memory_binding_targets` - - target type (`company`, `agent`, later `project`) + - target type (`company`, `agent`) - target id - binding id - `memory_operations` - company id - binding id - - operation type (`write`, `query`, `forget`, `browse`, `correct`) + - operation type (`capture`, `record_upsert`, `query`, `list`, `get`, `forget`, `correct`) - scope fields - source refs - - usage / latency / cost - - success / error + - usage, latency, and attribution mode + - related heartbeat run id + - related cost event id + - success or error + +- `memory_extraction_jobs` + - company id + - binding id + - operation id + - provider job id + - hook kind + - status + - source refs + - error + - submitted, started, and finished timestamps Provider-specific long-form state should stay in plugin state or the provider itself unless a built-in local provider needs its own schema. @@ -382,45 +666,46 @@ The design should still treat that built-in as just another provider behind the ### Phase 1: Control-plane contract - add memory binding models and API types -- add plugin capability / registration surface for memory providers -- add operation logging and usage reporting +- add company default plus agent override resolution +- add plugin capability and registration surface for memory providers -### Phase 2: One built-in + one plugin example +### Phase 2: Hook delivery and operation audit + +- add shared memory hook emission in core +- add operation logging, extraction job state, and usage attribution +- add direct-provider cost and finance-event linkage rules + +### Phase 3: One built-in plus one plugin example - ship a local markdown-first provider - ship one hosted adapter example to validate the external-provider path -### Phase 3: UI inspection +### Phase 4: UI inspection -- add company / agent memory settings +- add company and agent memory settings - add a memory operation explorer +- add record list and detail surfaces - add source backlinks to issues and runs -### Phase 4: Automatic hooks - -- pre-run hydrate -- post-run capture -- selected issue comment / document capture - ### Phase 5: Rich capabilities - correction flows -- provider-native browse / graph views -- project-level overrides if needed +- provider-native browse or graph views - evaluation dashboards +- retention and quota controls -## Open Questions +## Remaining Open Questions -- Should project overrides exist in V1 of the memory service, or should we force company default + agent override first? -- Do we want Paperclip-managed extraction pipelines at all, or should built-ins be the only place where Paperclip owns extraction? -- Should memory usage extend the current `cost_events` model directly, or should memory operations keep a parallel usage log and roll up into `cost_events` secondarily? -- Do we want provider install / binding changes to require approvals for some companies? +- Which built-in local provider should ship first: pure markdown, markdown plus embeddings, or a lightweight local vector store? +- How much source payload should Paperclip snapshot inside `memory_operations` for debugging without duplicating large transcripts? +- Should correction flows mutate provider state directly, create superseding records, or both depending on provider capability? +- What default retention and size limits should the local built-in enforce? ## Bottom Line The right abstraction is: -- Paperclip owns memory bindings, scopes, provenance, governance, and usage reporting. +- Paperclip owns bindings, resolution, hooks, provenance, policy, and attribution. - Providers own extraction, ranking, storage, and provider-native memory semantics. -That gives Paperclip a stable "memory service" without locking the product to one memory philosophy or one vendor. +That gives Paperclip a stable memory service without locking the product to one memory philosophy or one vendor, and it integrates the AWS lessons without importing AWS's model into core. diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index ff30263b949..927e7cf3b0f 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -20,6 +20,7 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | +| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster | | `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) | ## Session Persistence @@ -30,8 +31,22 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten. +## Fast Mode + +When `fastMode` is enabled, Paperclip adds Codex config overrides equivalent to: + +```sh +-c 'service_tier="fast"' -c 'features.fast_mode=true' +``` + +Paperclip currently applies that only when the selected model is `gpt-5.4`. On other models, the toggle is preserved in config but ignored at execution time to avoid unsupported runs. + +## Managed `CODEX_HOME` + When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity. +## Manual Local CLI + For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: ```sh diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 448ab7bbc4f..bb3cf17f11d 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -89,6 +89,8 @@ Show resolved environment configuration: pnpm paperclipai env ``` +This now includes bind-oriented deployment settings such as `PAPERCLIP_BIND` and `PAPERCLIP_BIND_HOST` when configured. + ## `paperclipai allowed-hostname` Allow a private hostname for authenticated/private mode: diff --git a/docs/deploy/deployment-modes.md b/docs/deploy/deployment-modes.md index ae567f49b57..7d5e6032f6a 100644 --- a/docs/deploy/deployment-modes.md +++ b/docs/deploy/deployment-modes.md @@ -3,13 +3,14 @@ title: Deployment Modes summary: local_trusted vs authenticated (private/public) --- -Paperclip supports two runtime modes with different security profiles. +Paperclip supports two runtime modes with different security profiles. Reachability is configured separately with `bind`. ## `local_trusted` The default mode. Optimized for single-operator local use. - **Host binding**: loopback only (localhost) +- **Bind**: `loopback` - **Authentication**: no login required - **Use case**: local development, solo experimentation - **Board identity**: auto-created local board user @@ -31,6 +32,7 @@ For private network access (Tailscale, VPN, LAN). - **Authentication**: login required via Better Auth - **URL handling**: auto base URL mode (lower friction) - **Host trust**: private-host trust policy required +- **Bind**: choose `loopback`, `lan`, `tailnet`, or `custom` ```sh pnpm paperclipai onboard @@ -50,6 +52,7 @@ For internet-facing deployment. - **Authentication**: login required - **URL**: explicit public URL required - **Security**: stricter deployment checks in doctor +- **Bind**: usually `loopback` behind a reverse proxy; `lan/custom` is advanced ```sh pnpm paperclipai onboard @@ -81,5 +84,5 @@ pnpm paperclipai configure --section server Runtime override via environment variable: ```sh -PAPERCLIP_DEPLOYMENT_MODE=authenticated pnpm paperclipai run +PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_BIND=lan pnpm paperclipai run ``` diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index bc5e4bcc132..5ebef728266 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -10,11 +10,14 @@ All environment variables that Paperclip uses for server configuration. | Variable | Default | Description | |----------|---------|-------------| | `PORT` | `3100` | Server port | -| `HOST` | `127.0.0.1` | Server host binding | +| `PAPERCLIP_BIND` | `loopback` | Reachability preset: `loopback`, `lan`, `tailnet`, or `custom` | +| `PAPERCLIP_BIND_HOST` | (unset) | Required when `PAPERCLIP_BIND=custom` | +| `HOST` | `127.0.0.1` | Legacy host override; prefer `PAPERCLIP_BIND` for new setups | | `DATABASE_URL` | (embedded) | PostgreSQL connection string | | `PAPERCLIP_HOME` | `~/.paperclip` | Base directory for all Paperclip data | | `PAPERCLIP_INSTANCE_ID` | `default` | Instance identifier (for multiple local instances) | | `PAPERCLIP_DEPLOYMENT_MODE` | `local_trusted` | Runtime mode override | +| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | `private` | Exposure policy when deployment mode is `authenticated` | ## Secrets diff --git a/docs/deploy/local-development.md b/docs/deploy/local-development.md index 874477c1332..3bc423341e9 100644 --- a/docs/deploy/local-development.md +++ b/docs/deploy/local-development.md @@ -38,19 +38,26 @@ This does: 2. Runs `paperclipai doctor` with repair enabled 3. Starts the server when checks pass -## Tailscale/Private Auth Dev Mode +## Bind Presets In Dev -To run in `authenticated/private` mode for network access: +Default `pnpm dev` stays in `local_trusted` with loopback-only binding. + +To open Paperclip to a private network with login enabled: + +```sh +pnpm dev --bind lan +``` + +For Tailscale-only binding on a detected tailnet address: + +```sh +pnpm dev --bind tailnet +``` + +Legacy aliases still work and map to the older broad private-network behavior: ```sh pnpm dev --tailscale-auth -``` - -This binds the server to `0.0.0.0` for private-network access. - -Alias: - -```sh pnpm dev --authenticated-private ``` diff --git a/docs/deploy/tailscale-private-access.md b/docs/deploy/tailscale-private-access.md index 1e0d2467528..bb40fa305a3 100644 --- a/docs/deploy/tailscale-private-access.md +++ b/docs/deploy/tailscale-private-access.md @@ -1,6 +1,6 @@ --- title: Tailscale Private Access -summary: Run Paperclip with Tailscale-friendly host binding and connect from other devices +summary: Run Paperclip with Tailscale-friendly bind presets and connect from other devices --- Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) instead of only `localhost`. @@ -8,20 +8,25 @@ Use this when you want to access Paperclip over Tailscale (or a private LAN/VPN) ## 1. Start Paperclip in private authenticated mode ```sh -pnpm dev --tailscale-auth +pnpm dev --bind tailnet ``` -This configures: +Recommended behavior: - `PAPERCLIP_DEPLOYMENT_MODE=authenticated` - `PAPERCLIP_DEPLOYMENT_EXPOSURE=private` -- `PAPERCLIP_AUTH_BASE_URL_MODE=auto` -- `HOST=0.0.0.0` (bind on all interfaces) +- `PAPERCLIP_BIND=tailnet` -Equivalent flag: +If you want the old broad private-network behavior instead, use: ```sh +pnpm dev --bind lan +``` + +Legacy aliases still map to `authenticated/private + bind=lan`: + pnpm dev --authenticated-private +pnpm dev --tailscale-auth ``` ## 2. Find your reachable Tailscale address @@ -73,5 +78,5 @@ Expected result: ## Troubleshooting - Login or redirect errors on a private hostname: add it with `paperclipai allowed-hostname`. -- App only works on `localhost`: make sure you started with `--tailscale-auth` (or set `HOST=0.0.0.0` in private mode). +- App only works on `localhost`: make sure you started with `--bind lan` or `--bind tailnet` instead of plain `pnpm dev`. - Can connect locally but not remotely: verify both devices are on the same Tailscale network and port `3100` is reachable. diff --git a/package.json b/package.json index 3fcc8d994db..cad35a7bb1a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "preflight:workspace-links": "node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts", "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", @@ -10,10 +11,10 @@ "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", - "typecheck": "pnpm -r typecheck", - "test": "vitest", - "test:run": "vitest run", + "build": "pnpm run preflight:workspace-links && pnpm -r build", + "typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck", + "test": "pnpm run preflight:workspace-links && vitest", + "test:run": "pnpm run preflight:workspace-links && vitest run", "db:generate": "pnpm --filter @paperclipai/db generate", "db:migrate": "pnpm --filter @paperclipai/db migrate", "secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts", diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index 62e395b0eb2..bcdc4ba09e0 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -2,6 +2,24 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; import { runChildProcess } from "./server-utils.js"; +function isPidAlive(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitForPidExit(pid: number, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isPidAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return !isPidAlive(pid); +} + describe("runChildProcess", () => { it("waits for onSpawn before sending stdin to the child", async () => { const spawnDelayMs = 150; @@ -35,4 +53,36 @@ describe("runChildProcess", () => { expect(onSpawnCompletedAt).toBeGreaterThanOrEqual(startedAt + spawnDelayMs); expect(finishedAt - startedAt).toBeGreaterThanOrEqual(spawnDelayMs); }); + + it.skipIf(process.platform === "win32")("kills descendant processes on timeout via the process group", async () => { + let descendantPid: number | null = null; + + const result = await runChildProcess( + randomUUID(), + process.execPath, + [ + "-e", + [ + "const { spawn } = require('node:child_process');", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", + "process.stdout.write(String(child.pid));", + "setInterval(() => {}, 1000);", + ].join(" "), + ], + { + cwd: process.cwd(), + env: {}, + timeoutSec: 1, + graceSec: 1, + onLog: async () => {}, + onSpawn: async () => {}, + }, + ); + + descendantPid = Number.parseInt(result.stdout.trim(), 10); + expect(result.timedOut).toBe(true); + expect(Number.isInteger(descendantPid) && descendantPid > 0).toBe(true); + + expect(await waitForPidExit(descendantPid!, 2_000)).toBe(true); + }); }); diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 83dbe06f1ea..3c0e0db7217 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -19,6 +19,7 @@ export interface RunProcessResult { interface RunningProcess { child: ChildProcess; graceSec: number; + processGroupId: number | null; } interface SpawnTarget { @@ -34,6 +35,28 @@ type ChildProcessWithEvents = ChildProcess & { ): ChildProcess; }; +function resolveProcessGroupId(child: ChildProcess) { + if (process.platform === "win32") return null; + return typeof child.pid === "number" && child.pid > 0 ? child.pid : null; +} + +function signalRunningProcess( + running: Pick, + signal: NodeJS.Signals, +) { + if (process.platform !== "win32" && running.processGroupId && running.processGroupId > 0) { + try { + process.kill(-running.processGroupId, signal); + return; + } catch { + // Fall back to the direct child signal if group signaling fails. + } + } + if (!running.child.killed) { + running.child.kill(signal); + } +} + export const runningProcesses = new Map(); export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024; export const MAX_EXCERPT_BYTES = 32 * 1024; @@ -1034,7 +1057,7 @@ export async function runChildProcess( graceSec: number; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onLogError?: (err: unknown, runId: string, message: string) => void; - onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; stdin?: string; }, ): Promise { @@ -1064,19 +1087,21 @@ export async function runChildProcess( const child = spawn(target.command, target.args, { cwd: opts.cwd, env: mergedEnv, + detached: process.platform !== "win32", shell: false, stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"], }) as ChildProcessWithEvents; const startedAt = new Date().toISOString(); + const processGroupId = resolveProcessGroupId(child); const spawnPersistPromise = typeof child.pid === "number" && child.pid > 0 && opts.onSpawn - ? opts.onSpawn({ pid: child.pid, startedAt }).catch((err) => { + ? opts.onSpawn({ pid: child.pid, processGroupId, startedAt }).catch((err) => { onLogError(err, runId, "failed to record child process metadata"); }) : Promise.resolve(); - runningProcesses.set(runId, { child, graceSec: opts.graceSec }); + runningProcesses.set(runId, { child, graceSec: opts.graceSec, processGroupId }); let timedOut = false; let stdout = ""; @@ -1087,11 +1112,9 @@ export async function runChildProcess( opts.timeoutSec > 0 ? setTimeout(() => { timedOut = true; - child.kill("SIGTERM"); + signalRunningProcess({ child, processGroupId }, "SIGTERM"); setTimeout(() => { - if (!child.killed) { - child.kill("SIGKILL"); - } + signalRunningProcess({ child, processGroupId }, "SIGKILL"); }, Math.max(1, opts.graceSec) * 1000); }, opts.timeoutSec * 1000) : null; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 429143d53d6..534a5a59754 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -120,7 +120,7 @@ export interface AdapterExecutionContext { context: Record; onLog: (stream: "stdout" | "stderr", chunk: string) => Promise; onMeta?: (meta: AdapterInvocationMeta) => Promise; - onSpawn?: (meta: { pid: number; startedAt: string }) => Promise; + onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise; authToken?: string; } @@ -372,6 +372,7 @@ export interface CreateConfigValues { chrome: boolean; dangerouslySkipPermissions: boolean; search: boolean; + fastMode: boolean; dangerouslyBypassSandbox: boolean; command: string; args: string; diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index ae5ea3abd4d..f2da174e8eb 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -1,5 +1,4 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils"; @@ -33,35 +32,10 @@ import { } from "./parse.js"; import { resolveClaudeDesiredSkillNames } from "./skills.js"; import { isBedrockModelId } from "./models.js"; +import { prepareClaudePromptBundle } from "./prompt-cache.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); -/** - * Create a tmpdir with `.claude/skills/` containing symlinks to skills from - * the repo's `skills/` directory, so `--add-dir` makes Claude Code discover - * them as proper registered skills. - */ -async function buildSkillsDir(config: Record): Promise { - const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-")); - const target = path.join(tmp, ".claude", "skills"); - await fs.mkdir(target, { recursive: true }); - const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); - const desiredNames = new Set( - resolveClaudeDesiredSkillNames( - config, - availableEntries, - ), - ); - for (const entry of availableEntries) { - if (!desiredNames.has(entry.key)) continue; - await fs.symlink( - entry.source, - path.join(target, entry.runtimeName), - ); - } - return tmp; -} - interface ClaudeExecutionInput { runId: string; agent: AdapterExecutionContext["agent"]; @@ -361,47 +335,64 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); - const sessionId = canResumeSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { - await onLog( - "stdout", - `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, - ); - } - - let effectiveInstructionsFilePath: string | undefined; - let preparedInstructionsFile = false; - - const ensureEffectiveInstructionsFilePath = async (resumeSessionId: string | null) => { - if (resumeSessionId || !instructionsFilePath) return undefined; - if (preparedInstructionsFile) return effectiveInstructionsFilePath; - - preparedInstructionsFile = true; + const claudeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); + const desiredSkillNames = new Set(resolveClaudeDesiredSkillNames(config, claudeSkillEntries)); + // When instructionsFilePath is configured, build a stable content-addressed + // file that includes both the file content and the path directive, so we only + // need --append-system-prompt-file (Claude CLI forbids using both flags together). + let combinedInstructionsContents: string | null = null; + if (instructionsFilePath) { try { const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8"); - const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`; - const combinedPath = path.join(skillsDir, "agent-instructions.md"); - await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8"); - effectiveInstructionsFilePath = combinedPath; + const pathDirective = + `\nThe above agent instructions were loaded from ${instructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsFileDir}. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`; + combinedInstructionsContents = instructionsContent + pathDirective; } catch (err) { const reason = err instanceof Error ? err.message : String(err); await onLog( "stderr", `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, ); - effectiveInstructionsFilePath = undefined; } + } + const promptBundle = await prepareClaudePromptBundle({ + companyId: agent.companyId, + skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)), + instructionsContents: combinedInstructionsContents, + onLog, + }); + const effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined; - return effectiveInstructionsFilePath; - }; + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, ""); + const hasMatchingPromptBundle = + runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey; + const canResumeSession = + runtimeSessionId.length > 0 && + hasMatchingPromptBundle && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if ( + runtimeSessionId && + runtimeSessionCwd.length > 0 && + path.resolve(runtimeSessionCwd) !== path.resolve(cwd) + ) { + await onLog( + "stdout", + `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); + } + if (runtimeSessionId && runtimePromptBundleKey.length > 0 && runtimePromptBundleKey !== promptBundle.bundleKey) { + await onLog( + "stdout", + `[paperclip] Claude session "${runtimeSessionId}" was saved for prompt bundle "${runtimePromptBundleKey}" and will not be resumed with "${promptBundle.bundleKey}".\n`, + ); + } const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); const templateData = { agentId: agent.id, @@ -456,7 +447,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); return args; }; @@ -478,14 +469,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const attemptInstructionsFilePath = await ensureEffectiveInstructionsFilePath(resumeSessionId); + const attemptInstructionsFilePath = resumeSessionId ? undefined : effectiveInstructionsFilePath; const args = buildClaudeArgs(resumeSessionId, attemptInstructionsFilePath); - const commandNotes = - attemptInstructionsFilePath && !resumeSessionId - ? [ - `Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`, - ] - : []; + const commandNotes: string[] = []; + if (!resumeSessionId) { + commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`); + } + if (attemptInstructionsFilePath && !resumeSessionId) { + commandNotes.push( + `Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`, + ); + } if (onMeta) { await onMeta({ adapterType: "claude_local", @@ -582,6 +576,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise {}); + const initial = await runAttempt(sessionId ?? null); + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) !== 0 && + initial.parsed && + isClaudeUnknownSessionError(initial.parsed) + ) { + await onLog( + "stdout", + `[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true }); } + + return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId }); } diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 5da522a6fc4..6c5c2782dd3 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -36,12 +36,16 @@ export const sessionCodec: AdapterSessionCodec = { readNonEmptyString(record.cwd) ?? readNonEmptyString(record.workdir) ?? readNonEmptyString(record.folder); + const promptBundleKey = + readNonEmptyString(record.promptBundleKey) ?? + readNonEmptyString(record.prompt_bundle_key); const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id); const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url); const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref); return { sessionId, ...(cwd ? { cwd } : {}), + ...(promptBundleKey ? { promptBundleKey } : {}), ...(workspaceId ? { workspaceId } : {}), ...(repoUrl ? { repoUrl } : {}), ...(repoRef ? { repoRef } : {}), @@ -55,12 +59,16 @@ export const sessionCodec: AdapterSessionCodec = { readNonEmptyString(params.cwd) ?? readNonEmptyString(params.workdir) ?? readNonEmptyString(params.folder); + const promptBundleKey = + readNonEmptyString(params.promptBundleKey) ?? + readNonEmptyString(params.prompt_bundle_key); const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id); const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url); const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref); return { sessionId, ...(cwd ? { cwd } : {}), + ...(promptBundleKey ? { promptBundleKey } : {}), ...(workspaceId ? { workspaceId } : {}), ...(repoUrl ? { repoUrl } : {}), ...(repoRef ? { repoRef } : {}), diff --git a/packages/adapters/claude-local/src/server/prompt-cache.ts b/packages/adapters/claude-local/src/server/prompt-cache.ts new file mode 100644 index 00000000000..ce719799979 --- /dev/null +++ b/packages/adapters/claude-local/src/server/prompt-cache.ts @@ -0,0 +1,172 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createHash, type Hash } from "node:crypto"; +import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; +import { ensurePaperclipSkillSymlink, type PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils"; + +const DEFAULT_PAPERCLIP_INSTANCE_ID = "default"; + +type SkillEntry = PaperclipSkillEntry; + +export interface ClaudePromptBundle { + bundleKey: string; + rootDir: string; + addDir: string; + instructionsFilePath: string | null; +} + +function nonEmpty(value: string | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveManagedClaudePromptCacheRoot( + env: NodeJS.ProcessEnv, + companyId: string, +): string { + const paperclipHome = nonEmpty(env.PAPERCLIP_HOME) ?? path.resolve(os.homedir(), ".paperclip"); + const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? DEFAULT_PAPERCLIP_INSTANCE_ID; + return path.resolve( + paperclipHome, + "instances", + instanceId, + "companies", + companyId, + "claude-prompt-cache", + ); +} + +async function hashPathContents( + candidate: string, + hash: Hash, + relativePath: string, + seenDirectories: Set, +): Promise { + const stat = await fs.lstat(candidate); + + if (stat.isSymbolicLink()) { + hash.update(`symlink:${relativePath}\n`); + const resolved = await fs.realpath(candidate).catch(() => null); + if (!resolved) { + hash.update("missing\n"); + return; + } + await hashPathContents(resolved, hash, relativePath, seenDirectories); + return; + } + + if (stat.isDirectory()) { + const realDir = await fs.realpath(candidate).catch(() => candidate); + hash.update(`dir:${relativePath}\n`); + if (seenDirectories.has(realDir)) { + hash.update("loop\n"); + return; + } + seenDirectories.add(realDir); + const entries = await fs.readdir(candidate, { withFileTypes: true }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name; + await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories); + } + return; + } + + if (stat.isFile()) { + hash.update(`file:${relativePath}\n`); + hash.update(await fs.readFile(candidate)); + hash.update("\n"); + return; + } + + hash.update(`other:${relativePath}:${stat.mode}\n`); +} + +async function buildClaudePromptBundleKey(input: { + skills: SkillEntry[]; + instructionsContents: string | null; +}): Promise { + const hash = createHash("sha256"); + hash.update("paperclip-claude-prompt-bundle:v1\n"); + if (input.instructionsContents) { + hash.update("instructions\n"); + hash.update(input.instructionsContents); + hash.update("\n"); + } else { + hash.update("instructions:none\n"); + } + + const sortedSkills = [...input.skills].sort((left, right) => left.runtimeName.localeCompare(right.runtimeName)); + for (const entry of sortedSkills) { + hash.update(`skill:${entry.key}:${entry.runtimeName}\n`); + await hashPathContents(entry.source, hash, entry.runtimeName, new Set()); + } + + return hash.digest("hex"); +} + +async function ensureReadableFile(targetPath: string, contents: string): Promise { + try { + await fs.access(targetPath, fsConstants.R_OK); + return; + } catch { + // Fall through and materialize the file. + } + + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp`; + try { + await fs.writeFile(tempPath, contents, "utf8"); + await fs.rename(tempPath, targetPath); + } catch (err) { + const targetReadable = await fs.access(targetPath, fsConstants.R_OK).then(() => true).catch(() => false); + if (!targetReadable) { + throw err; + } + } finally { + await fs.rm(tempPath, { force: true }).catch(() => {}); + } +} + +export async function prepareClaudePromptBundle(input: { + companyId: string; + skills: SkillEntry[]; + instructionsContents: string | null; + onLog: AdapterExecutionContext["onLog"]; +}): Promise { + const { companyId, skills, instructionsContents, onLog } = input; + const bundleKey = await buildClaudePromptBundleKey({ + skills, + instructionsContents, + }); + const rootDir = path.join(resolveManagedClaudePromptCacheRoot(process.env, companyId), bundleKey); + const skillsHome = path.join(rootDir, ".claude", "skills"); + await fs.mkdir(skillsHome, { recursive: true }); + + for (const entry of skills) { + const target = path.join(skillsHome, entry.runtimeName); + try { + await ensurePaperclipSkillSymlink(entry.source, target); + } catch (err) { + await onLog( + "stderr", + `[paperclip] Failed to materialize Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + } + + const instructionsFilePath = instructionsContents + ? path.join(rootDir, "agent-instructions.md") + : null; + if (instructionsFilePath && instructionsContents) { + await ensureReadableFile(instructionsFilePath, instructionsContents); + } + + return { + bundleKey, + rootDir, + addDir: rootDir, + instructionsFilePath, + }; +} diff --git a/packages/adapters/claude-local/src/server/skills.ts b/packages/adapters/claude-local/src/server/skills.ts index b88cc54ee97..75446393606 100644 --- a/packages/adapters/claude-local/src/server/skills.ts +++ b/packages/adapters/claude-local/src/server/skills.ts @@ -47,7 +47,7 @@ async function buildClaudeSkillSnapshot(config: Record): Promis sourcePath: entry.source, targetPath: null, detail: desiredSet.has(entry.key) - ? "Will be mounted into the ephemeral Claude skill directory on the next run." + ? "Will be materialized into the stable Paperclip-managed Claude prompt bundle on the next run." : null, required: Boolean(entry.required), requiredReason: entry.requiredReason ?? null, diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index ca795cb5182..cbafb2d1d1a 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -2,6 +2,14 @@ export const type = "codex_local"; export const label = "Codex (local)"; export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex"; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; +export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const; + +export function isCodexLocalFastModeSupported(model: string | null | undefined): boolean { + const normalizedModel = typeof model === "string" ? model.trim() : ""; + return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.includes( + normalizedModel as (typeof CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS)[number], + ); +} export const models = [ { id: "gpt-5.4", label: "gpt-5.4" }, @@ -27,6 +35,7 @@ Core fields: - modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=... - promptTemplate (string, optional): run prompt template - search (boolean, optional): run codex with --search +- fastMode (boolean, optional): enable Codex Fast mode; currently supported on GPT-5.4 only and consumes credits faster - dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag - command (string, optional): defaults to "codex" - extraArgs (string[], optional): additional CLI args @@ -45,5 +54,6 @@ Notes: - Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances//companies//codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead. - Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex). - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). +- Fast mode is currently supported on GPT-5.4 only. When enabled, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts new file mode 100644 index 00000000000..c291ae53b79 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildCodexExecArgs } from "./codex-args.js"; + +describe("buildCodexExecArgs", () => { + it("enables Codex fast mode overrides for GPT-5.4", () => { + const result = buildCodexExecArgs({ + model: "gpt-5.4", + search: true, + fastMode: true, + }); + + expect(result.fastModeRequested).toBe(true); + expect(result.fastModeApplied).toBe(true); + expect(result.fastModeIgnoredReason).toBeNull(); + expect(result.args).toEqual([ + "--search", + "exec", + "--json", + "--model", + "gpt-5.4", + "-c", + 'service_tier="fast"', + "-c", + "features.fast_mode=true", + "-", + ]); + }); + + it("ignores fast mode for unsupported models", () => { + const result = buildCodexExecArgs({ + model: "gpt-5.3-codex", + fastMode: true, + }); + + expect(result.fastModeRequested).toBe(true); + expect(result.fastModeApplied).toBe(false); + expect(result.fastModeIgnoredReason).toContain("currently only supported on gpt-5.4"); + expect(result.args).toEqual([ + "exec", + "--json", + "--model", + "gpt-5.3-codex", + "-", + ]); + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-args.ts b/packages/adapters/codex-local/src/server/codex-args.ts new file mode 100644 index 00000000000..76756814182 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-args.ts @@ -0,0 +1,74 @@ +import { asBoolean, asString, asStringArray } from "@paperclipai/adapter-utils/server-utils"; +import { + CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, + isCodexLocalFastModeSupported, +} from "../index.js"; + +export type BuildCodexExecArgsResult = { + args: string[]; + model: string; + fastModeRequested: boolean; + fastModeApplied: boolean; + fastModeIgnoredReason: string | null; +}; + +function readExtraArgs(config: unknown): string[] { + const fromExtraArgs = asStringArray(asRecord(config).extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(asRecord(config).args); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function formatFastModeSupportedModels(): string { + return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", "); +} + +export function buildCodexExecArgs( + config: unknown, + options: { resumeSessionId?: string | null } = {}, +): BuildCodexExecArgsResult { + const record = asRecord(config); + const model = asString(record.model, "").trim(); + const modelReasoningEffort = asString( + record.modelReasoningEffort, + asString(record.reasoningEffort, ""), + ).trim(); + const search = asBoolean(record.search, false); + const fastModeRequested = asBoolean(record.fastMode, false); + const fastModeApplied = fastModeRequested && isCodexLocalFastModeSupported(model); + const bypass = asBoolean( + record.dangerouslyBypassApprovalsAndSandbox, + asBoolean(record.dangerouslyBypassSandbox, false), + ); + const extraArgs = readExtraArgs(record); + + const args = ["exec", "--json"]; + if (search) args.unshift("--search"); + if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); + if (model) args.push("--model", model); + if (modelReasoningEffort) { + args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`); + } + if (fastModeApplied) { + args.push("-c", 'service_tier="fast"', "-c", "features.fast_mode=true"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + if (options.resumeSessionId) args.push("resume", options.resumeSessionId, "-"); + else args.push("-"); + + return { + args, + model, + fastModeRequested, + fastModeApplied, + fastModeIgnoredReason: + fastModeRequested && !fastModeApplied + ? `Configured fast mode is currently only supported on ${formatFastModeSupportedModels()}; Paperclip will ignore it for model ${model || "(default)"}.` + : null, + }; +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index c6ba3e9ba9f..f34c09ca53f 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -5,8 +5,6 @@ import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type Adapter import { asString, asNumber, - asBoolean, - asStringArray, parseObject, buildPaperclipEnv, buildInvocationEnvForLogs, @@ -26,6 +24,7 @@ import { import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; +import { buildCodexExecArgs } from "./codex-args.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE = @@ -223,15 +222,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - 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 ?? ""); @@ -499,26 +484,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = ["exec", "--json"]; - if (search) args.unshift("--search"); - if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); - if (model) args.push("--model", model); - if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`); - if (extraArgs.length > 0) args.push(...extraArgs); - if (resumeSessionId) args.push("resume", resumeSessionId, "-"); - else args.push("-"); - return args; - }; - const runAttempt = async (resumeSessionId: string | null) => { - const args = buildArgs(resumeSessionId); + const execArgs = buildCodexExecArgs(config, { resumeSessionId }); + const args = execArgs.args; + const commandNotesWithFastMode = + execArgs.fastModeIgnoredReason == null + ? commandNotes + : [...commandNotes, execArgs.fastModeIgnoredReason]; if (onMeta) { await onMeta({ adapterType: "codex_local", command: resolvedCommand, cwd, - commandNotes, + commandNotes: commandNotesWithFastMode, commandArgs: args.map((value, idx) => { if (idx === args.length - 1 && value !== "-") return ``; return value; diff --git a/packages/adapters/codex-local/src/server/parse.test.ts b/packages/adapters/codex-local/src/server/parse.test.ts index b2b33e4aa5d..6114cae6433 100644 --- a/packages/adapters/codex-local/src/server/parse.test.ts +++ b/packages/adapters/codex-local/src/server/parse.test.ts @@ -27,6 +27,39 @@ describe("parseCodexJsonl", () => { errorMessage: "resume failed", }); }); + + it("uses the last agent message as the summary when commentary updates precede the final answer", () => { + const stdout = [ + JSON.stringify({ type: "thread.started", thread_id: "thread_123" }), + JSON.stringify({ + type: "item.completed", + item: { type: "reasoning", text: "Checking the heartbeat procedure" }, + }), + JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: "I’m checking out the issue and reading the docs now." }, + }), + JSON.stringify({ + type: "item.completed", + item: { type: "agent_message", text: "Fixed the issue and verified the targeted tests pass." }, + }), + JSON.stringify({ + type: "turn.completed", + usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 4 }, + }), + ].join("\n"); + + expect(parseCodexJsonl(stdout)).toEqual({ + sessionId: "thread_123", + summary: "Fixed the issue and verified the targeted tests pass.", + usage: { + inputTokens: 10, + cachedInputTokens: 2, + outputTokens: 4, + }, + errorMessage: null, + }); + }); }); describe("isCodexUnknownSessionError", () => { diff --git a/packages/adapters/codex-local/src/server/parse.ts b/packages/adapters/codex-local/src/server/parse.ts index 1a7aa6a7e16..5d73b3babe0 100644 --- a/packages/adapters/codex-local/src/server/parse.ts +++ b/packages/adapters/codex-local/src/server/parse.ts @@ -2,7 +2,7 @@ import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter export function parseCodexJsonl(stdout: string) { let sessionId: string | null = null; - const messages: string[] = []; + let finalMessage: string | null = null; let errorMessage: string | null = null; const usage = { inputTokens: 0, @@ -33,7 +33,7 @@ export function parseCodexJsonl(stdout: string) { const item = parseObject(event.item); if (asString(item.type, "") === "agent_message") { const text = asString(item.text, ""); - if (text) messages.push(text); + if (text) finalMessage = text; } continue; } @@ -55,7 +55,7 @@ export function parseCodexJsonl(stdout: string) { return { sessionId, - summary: messages.join("\n\n").trim(), + summary: finalMessage?.trim() ?? "", usage, errorMessage, }; diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 64af601b7b4..f19ed0078cc 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -5,8 +5,6 @@ import type { } from "@paperclipai/adapter-utils"; import { asString, - asBoolean, - asStringArray, parseObject, ensureAbsoluteDirectory, ensureCommandResolvable, @@ -16,6 +14,7 @@ import { import path from "node:path"; import { parseCodexJsonl } from "./parse.js"; import { codexHomeDir, readCodexAuthInfo } from "./quota.js"; +import { buildCodexExecArgs } from "./codex-args.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -140,31 +139,16 @@ export async function testEnvironment( hint: "Use the `codex` CLI command to run the automatic login and installation probe.", }); } else { - const model = asString(config.model, "").trim(); - const modelReasoningEffort = asString( - config.modelReasoningEffort, - asString(config.reasoningEffort, ""), - ).trim(); - const search = asBoolean(config.search, false); - const bypass = asBoolean( - config.dangerouslyBypassApprovalsAndSandbox, - asBoolean(config.dangerouslyBypassSandbox, false), - ); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - - const args = ["exec", "--json"]; - if (search) args.unshift("--search"); - if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); - if (model) args.push("--model", model); - if (modelReasoningEffort) { - args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`); + const execArgs = buildCodexExecArgs({ ...config, fastMode: false }); + const args = execArgs.args; + if (execArgs.fastModeIgnoredReason) { + checks.push({ + code: "codex_fast_mode_unsupported_model", + level: "warn", + message: execArgs.fastModeIgnoredReason, + hint: "Switch the agent model to GPT-5.4 to enable Codex Fast mode.", + }); } - if (extraArgs.length > 0) args.push(...extraArgs); - args.push("-"); const probe = await runChildProcess( `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts new file mode 100644 index 00000000000..734d16872f7 --- /dev/null +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { buildCodexLocalConfig } from "./build-config.js"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function makeValues(overrides: Partial = {}): CreateConfigValues { + return { + adapterType: "codex_local", + cwd: "", + instructionsFilePath: "", + promptTemplate: "", + model: "gpt-5.4", + thinkingEffort: "", + chrome: false, + dangerouslySkipPermissions: true, + search: false, + fastMode: false, + dangerouslyBypassSandbox: true, + command: "", + args: "", + extraArgs: "", + envVars: "", + envBindings: {}, + url: "", + bootstrapPrompt: "", + payloadTemplateJson: "", + workspaceStrategyType: "project_primary", + workspaceBaseRef: "", + workspaceBranchTemplate: "", + worktreeParentDir: "", + runtimeServicesJson: "", + maxTurnsPerRun: 1000, + heartbeatEnabled: false, + intervalSec: 300, + ...overrides, + }; +} + +describe("buildCodexLocalConfig", () => { + it("persists the fastMode toggle into adapter config", () => { + const config = buildCodexLocalConfig( + makeValues({ + search: true, + fastMode: true, + }), + ); + + expect(config).toMatchObject({ + model: "gpt-5.4", + search: true, + fastMode: true, + dangerouslyBypassApprovalsAndSandbox: true, + }); + }); +}); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 7c8f0d9cc58..8721ba768fb 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -85,6 +85,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.search = v.search; + ac.fastMode = v.fastMode; ac.dangerouslyBypassApprovalsAndSandbox = typeof v.dangerouslyBypassSandbox === "boolean" ? v.dangerouslyBypassSandbox diff --git a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md index 66ff2a4aaca..7382c67af77 100644 --- a/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md +++ b/packages/adapters/openclaw-gateway/doc/ONBOARDING_AND_TEST_PLAN.md @@ -66,7 +66,7 @@ OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh ### 1) Start Paperclip ```bash -pnpm dev --tailscale-auth +pnpm dev --bind lan curl -fsS http://127.0.0.1:3100/api/health ``` diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index dcdc87c50e4..2ea9b07001c 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -125,12 +125,12 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { const result = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir, - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip-test", }); - expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/); - expect(result.sizeBytes).toBeGreaterThan(1024 * 1024); + expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/); + expect(result.sizeBytes).toBeGreaterThan(0); expect(fs.existsSync(result.backupFile)).toBe(true); await runDatabaseRestore({ diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index ea76a2b6d3c..e1e88724f8a 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,12 +1,20 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { pipeline } from "node:stream/promises"; +import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; +export type BackupRetentionPolicy = { + dailyDays: number; + weeklyWeeks: number; + monthlyMonths: number; +}; + export type RunDatabaseBackupOptions = { connectionString: string; backupDir: string; - retentionDays: number; + retention: BackupRetentionPolicy; filenamePrefix?: string; connectTimeoutSeconds?: number; includeMigrationJournal?: boolean; @@ -75,23 +83,91 @@ function timestamp(date: Date = new Date()): string { return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } -function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefix: string): number { +/** + * ISO week key for grouping backups by calendar week (ISO 8601). + */ +function isoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +/** + * Tiered backup pruning: + * - Daily tier: keep ALL backups from the last `dailyDays` days + * - Weekly tier: keep the NEWEST backup per calendar week for `weeklyWeeks` weeks + * - Monthly tier: keep the NEWEST backup per calendar month for `monthlyMonths` months + * - Everything else is deleted + */ +function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, filenamePrefix: string): number { if (!existsSync(backupDir)) return 0; - const safeRetention = Math.max(1, Math.trunc(retentionDays)); - const cutoff = Date.now() - safeRetention * 24 * 60 * 60 * 1000; - let pruned = 0; + + const now = Date.now(); + const dailyCutoff = now - Math.max(1, retention.dailyDays) * 24 * 60 * 60 * 1000; + const weeklyCutoff = now - Math.max(1, retention.weeklyWeeks) * 7 * 24 * 60 * 60 * 1000; + const monthlyCutoff = now - Math.max(1, retention.monthlyMonths) * 30 * 24 * 60 * 60 * 1000; + + type BackupEntry = { name: string; fullPath: string; mtimeMs: number }; + const entries: BackupEntry[] = []; for (const name of readdirSync(backupDir)) { - if (!name.startsWith(`${filenamePrefix}-`) || !name.endsWith(".sql")) continue; + if (!name.startsWith(`${filenamePrefix}-`)) continue; + if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); - if (stat.mtimeMs < cutoff) { - unlinkSync(fullPath); - pruned++; - } + entries.push({ name, fullPath, mtimeMs: stat.mtimeMs }); } - return pruned; + // Sort newest first so the first entry per week/month bucket is the one we keep + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const keepWeekBuckets = new Set(); + const keepMonthBuckets = new Set(); + const toDelete: string[] = []; + + for (const entry of entries) { + // Daily tier — keep everything within dailyDays + if (entry.mtimeMs >= dailyCutoff) continue; + + const date = new Date(entry.mtimeMs); + const week = isoWeekKey(date); + const month = monthKey(date); + + // Weekly tier — keep newest per calendar week + if (entry.mtimeMs >= weeklyCutoff) { + if (keepWeekBuckets.has(week)) { + toDelete.push(entry.fullPath); + } else { + keepWeekBuckets.add(week); + } + continue; + } + + // Monthly tier — keep newest per calendar month + if (entry.mtimeMs >= monthlyCutoff) { + if (keepMonthBuckets.has(month)) { + toDelete.push(entry.fullPath); + } else { + keepMonthBuckets.add(month); + } + continue; + } + + // Beyond all retention tiers — delete + toDelete.push(entry.fullPath); + } + + for (const filePath of toDelete) { + unlinkSync(filePath); + } + + return toDelete.length; } function formatBackupSize(sizeBytes: number): string { @@ -148,7 +224,9 @@ function tableKey(schemaName: string, tableName: string): string { } async function* readRestoreStatements(backupFile: string): AsyncGenerator { - const stream = createReadStream(backupFile, { encoding: "utf8" }); + const raw = createReadStream(backupFile); + const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + stream.setEncoding("utf8"); const reader = createInterface({ input: stream, crlfDelay: Infinity, @@ -180,6 +258,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; - const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); + const retention = opts.retention; const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); const includeMigrationJournal = opts.includeMigrationJournal === true; const excludedTableNames = normalizeTableNameSet(opts.excludeTables); const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns); const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); mkdirSync(opts.backupDir, { recursive: true }); - const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); - const writer = createBufferedTextFileWriter(backupFile); + const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); + const backupFile = `${sqlFile}.gz`; + const writer = createBufferedTextFileWriter(sqlFile); try { await sql`SELECT 1`; @@ -664,8 +744,14 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise await writer.close(); + // Compress the SQL file with gzip + const sqlReadStream = createReadStream(sqlFile); + const gzWriteStream = createWriteStream(backupFile); + await pipeline(sqlReadStream, createGzip(), gzWriteStream); + unlinkSync(sqlFile); + const sizeBytes = statSync(backupFile).size; - const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix); + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); return { backupFile, @@ -674,6 +760,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise }; } catch (error) { await writer.abort(); + if (existsSync(backupFile)) { + try { unlinkSync(backupFile); } catch { /* ignore */ } + } + if (existsSync(sqlFile)) { + try { unlinkSync(sqlFile); } catch { /* ignore */ } + } throw error; } finally { await sql.end(); diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index f07dc646f36..c11822c9503 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -85,7 +85,7 @@ function resolveBackupDir(config: PartialConfig | null): string { } function resolveRetentionDays(config: PartialConfig | null): number { - return asPositiveInt(config?.database?.backup?.retentionDays) ?? 30; + return asPositiveInt(config?.database?.backup?.retentionDays) ?? 7; } async function main() { @@ -103,7 +103,7 @@ async function main() { const result = await runDatabaseBackup({ connectionString, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip", }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cf4a26335fd..200e29813d1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -21,6 +21,7 @@ export { runDatabaseBackup, runDatabaseRestore, formatDatabaseBackupResult, + type BackupRetentionPolicy, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, type RunDatabaseRestoreOptions, diff --git a/packages/db/src/migrations/0055_kind_weapon_omega.sql b/packages/db/src/migrations/0055_kind_weapon_omega.sql new file mode 100644 index 00000000000..94bcb334848 --- /dev/null +++ b/packages/db/src/migrations/0055_kind_weapon_omega.sql @@ -0,0 +1 @@ +ALTER TABLE "heartbeat_runs" ADD COLUMN "process_group_id" integer;--> statement-breakpoint diff --git a/packages/db/src/migrations/meta/0055_snapshot.json b/packages/db/src/migrations/meta/0055_snapshot.json new file mode 100644 index 00000000000..55d557df961 --- /dev/null +++ b/packages/db/src/migrations/meta/0055_snapshot.json @@ -0,0 +1,13206 @@ +{ + "id": "dfe6d9fd-7969-4f13-a701-1fcc62bf0015", + "prevId": "eb8aba7f-540a-4ac6-9f58-1ed449707201", + "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 + }, + "feedback_data_sharing_enabled": { + "name": "feedback_data_sharing_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feedback_data_sharing_consent_at": { + "name": "feedback_data_sharing_consent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_consent_by_user_id": { + "name": "feedback_data_sharing_consent_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_data_sharing_terms_version": { + "name": "feedback_data_sharing_terms_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "companies_issue_prefix_idx": { + "name": "companies_issue_prefix_idx", + "columns": [ + { + "expression": "issue_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_logos": { + "name": "company_logos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_logos_company_uq": { + "name": "company_logos_company_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_logos_asset_uq": { + "name": "company_logos_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_logos_company_id_companies_id_fk": { + "name": "company_logos_company_id_companies_id_fk", + "tableFrom": "company_logos", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_logos_asset_id_assets_id_fk": { + "name": "company_logos_asset_id_assets_id_fk", + "tableFrom": "company_logos", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_memberships": { + "name": "company_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "principal_type": { + "name": "principal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "principal_id": { + "name": "principal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "membership_role": { + "name": "membership_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_memberships_company_principal_unique_idx": { + "name": "company_memberships_company_principal_unique_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_principal_status_idx": { + "name": "company_memberships_principal_status_idx", + "columns": [ + { + "expression": "principal_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "principal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_memberships_company_status_idx": { + "name": "company_memberships_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_memberships_company_id_companies_id_fk": { + "name": "company_memberships_company_id_companies_id_fk", + "tableFrom": "company_memberships", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secret_versions": { + "name": "company_secret_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "secret_id": { + "name": "secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "material": { + "name": "material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "value_sha256": { + "name": "value_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "company_secret_versions_secret_idx": { + "name": "company_secret_versions_secret_idx", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_value_sha256_idx": { + "name": "company_secret_versions_value_sha256_idx", + "columns": [ + { + "expression": "value_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secret_versions_secret_version_uq": { + "name": "company_secret_versions_secret_version_uq", + "columns": [ + { + "expression": "secret_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secret_versions_secret_id_company_secrets_id_fk": { + "name": "company_secret_versions_secret_id_company_secrets_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "company_secrets", + "columnsFrom": [ + "secret_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "company_secret_versions_created_by_agent_id_agents_id_fk": { + "name": "company_secret_versions_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secret_versions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_secrets": { + "name": "company_secrets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_encrypted'" + }, + "external_ref": { + "name": "external_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_secrets_company_idx": { + "name": "company_secrets_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_provider_idx": { + "name": "company_secrets_company_provider_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_secrets_company_name_uq": { + "name": "company_secrets_company_name_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_secrets_company_id_companies_id_fk": { + "name": "company_secrets_company_id_companies_id_fk", + "tableFrom": "company_secrets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "company_secrets_created_by_agent_id_agents_id_fk": { + "name": "company_secrets_created_by_agent_id_agents_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.company_skills": { + "name": "company_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_path'" + }, + "source_locator": { + "name": "source_locator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trust_level": { + "name": "trust_level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown_only'" + }, + "compatibility": { + "name": "compatibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'compatible'" + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skills_company_key_idx": { + "name": "company_skills_company_key_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "company_skills_company_name_idx": { + "name": "company_skills_company_name_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skills_company_id_companies_id_fk": { + "name": "company_skills_company_id_companies_id_fk", + "tableFrom": "company_skills", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cost_events": { + "name": "cost_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "billing_type": { + "name": "billing_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cost_events_company_occurred_idx": { + "name": "cost_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_agent_occurred_idx": { + "name": "cost_events_company_agent_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_provider_occurred_idx": { + "name": "cost_events_company_provider_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_biller_occurred_idx": { + "name": "cost_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cost_events_company_heartbeat_run_idx": { + "name": "cost_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cost_events_company_id_companies_id_fk": { + "name": "cost_events_company_id_companies_id_fk", + "tableFrom": "cost_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_agent_id_agents_id_fk": { + "name": "cost_events_agent_id_agents_id_fk", + "tableFrom": "cost_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_issue_id_issues_id_fk": { + "name": "cost_events_issue_id_issues_id_fk", + "tableFrom": "cost_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_project_id_projects_id_fk": { + "name": "cost_events_project_id_projects_id_fk", + "tableFrom": "cost_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_goal_id_goals_id_fk": { + "name": "cost_events_goal_id_goals_id_fk", + "tableFrom": "cost_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cost_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "cost_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "cost_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_revisions": { + "name": "document_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "change_summary": { + "name": "change_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "document_revisions_document_revision_uq": { + "name": "document_revisions_document_revision_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revision_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "document_revisions_company_document_created_idx": { + "name": "document_revisions_company_document_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_revisions_company_id_companies_id_fk": { + "name": "document_revisions_company_id_companies_id_fk", + "tableFrom": "document_revisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_revisions_document_id_documents_id_fk": { + "name": "document_revisions_document_id_documents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_revisions_created_by_agent_id_agents_id_fk": { + "name": "document_revisions_created_by_agent_id_agents_id_fk", + "tableFrom": "document_revisions", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_revisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "document_revisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "document_revisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'markdown'" + }, + "latest_body": { + "name": "latest_body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest_revision_id": { + "name": "latest_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_revision_number": { + "name": "latest_revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "documents_company_updated_idx": { + "name": "documents_company_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "documents_company_created_idx": { + "name": "documents_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_company_id_companies_id_fk": { + "name": "documents_company_id_companies_id_fk", + "tableFrom": "documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_created_by_agent_id_agents_id_fk": { + "name": "documents_created_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "documents_updated_by_agent_id_agents_id_fk": { + "name": "documents_updated_by_agent_id_agents_id_fk", + "tableFrom": "documents", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_workspaces": { + "name": "execution_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_workspace_id": { + "name": "project_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_issue_id": { + "name": "source_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy_type": { + "name": "strategy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_fs'" + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "derived_from_execution_workspace_id": { + "name": "derived_from_execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_eligible_at": { + "name": "cleanup_eligible_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cleanup_reason": { + "name": "cleanup_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_workspaces_company_project_status_idx": { + "name": "execution_workspaces_company_project_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_project_workspace_status_idx": { + "name": "execution_workspaces_company_project_workspace_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_source_issue_idx": { + "name": "execution_workspaces_company_source_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_last_used_idx": { + "name": "execution_workspaces_company_last_used_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspaces_company_branch_idx": { + "name": "execution_workspaces_company_branch_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_workspaces_company_id_companies_id_fk": { + "name": "execution_workspaces_company_id_companies_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "execution_workspaces_project_id_projects_id_fk": { + "name": "execution_workspaces_project_id_projects_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_workspaces_project_workspace_id_project_workspaces_id_fk": { + "name": "execution_workspaces_project_workspace_id_project_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "project_workspaces", + "columnsFrom": [ + "project_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_source_issue_id_issues_id_fk": { + "name": "execution_workspaces_source_issue_id_issues_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "issues", + "columnsFrom": [ + "source_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk": { + "name": "execution_workspaces_derived_from_execution_workspace_id_execution_workspaces_id_fk", + "tableFrom": "execution_workspaces", + "tableTo": "execution_workspaces", + "columnsFrom": [ + "derived_from_execution_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_exports": { + "name": "feedback_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feedback_vote_id": { + "name": "feedback_vote_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'local_only'" + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "export_id": { + "name": "export_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-envelope-v2'" + }, + "bundle_version": { + "name": "bundle_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-bundle-v2'" + }, + "payload_version": { + "name": "payload_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip-feedback-v1'" + }, + "payload_digest": { + "name": "payload_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_snapshot": { + "name": "payload_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_summary": { + "name": "target_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_exports_feedback_vote_idx": { + "name": "feedback_exports_feedback_vote_idx", + "columns": [ + { + "expression": "feedback_vote_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_created_idx": { + "name": "feedback_exports_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_status_idx": { + "name": "feedback_exports_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_issue_idx": { + "name": "feedback_exports_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_project_idx": { + "name": "feedback_exports_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_exports_company_author_idx": { + "name": "feedback_exports_company_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_exports_company_id_companies_id_fk": { + "name": "feedback_exports_company_id_companies_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_exports_feedback_vote_id_feedback_votes_id_fk": { + "name": "feedback_exports_feedback_vote_id_feedback_votes_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "feedback_votes", + "columnsFrom": [ + "feedback_vote_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_issue_id_issues_id_fk": { + "name": "feedback_exports_issue_id_issues_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_exports_project_id_projects_id_fk": { + "name": "feedback_exports_project_id_projects_id_fk", + "tableFrom": "feedback_exports", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feedback_votes": { + "name": "feedback_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_with_labs": { + "name": "shared_with_labs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consent_version": { + "name": "consent_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redaction_summary": { + "name": "redaction_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feedback_votes_company_issue_idx": { + "name": "feedback_votes_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_issue_target_idx": { + "name": "feedback_votes_issue_target_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_author_idx": { + "name": "feedback_votes_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "feedback_votes_company_target_author_idx": { + "name": "feedback_votes_company_target_author_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feedback_votes_company_id_companies_id_fk": { + "name": "feedback_votes_company_id_companies_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "feedback_votes_issue_id_issues_id_fk": { + "name": "feedback_votes_issue_id_issues_id_fk", + "tableFrom": "feedback_votes", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finance_events": { + "name": "finance_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heartbeat_run_id": { + "name": "heartbeat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_code": { + "name": "billing_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_kind": { + "name": "event_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'debit'" + }, + "biller": { + "name": "biller", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_adapter_type": { + "name": "execution_adapter_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pricing_tier": { + "name": "pricing_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "estimated": { + "name": "estimated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "external_invoice_id": { + "name": "external_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "finance_events_company_occurred_idx": { + "name": "finance_events_company_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_biller_occurred_idx": { + "name": "finance_events_company_biller_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "biller", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_kind_occurred_idx": { + "name": "finance_events_company_kind_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_direction_occurred_idx": { + "name": "finance_events_company_direction_occurred_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "direction", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_heartbeat_run_idx": { + "name": "finance_events_company_heartbeat_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "heartbeat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "finance_events_company_cost_event_idx": { + "name": "finance_events_company_cost_event_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "finance_events_company_id_companies_id_fk": { + "name": "finance_events_company_id_companies_id_fk", + "tableFrom": "finance_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_agent_id_agents_id_fk": { + "name": "finance_events_agent_id_agents_id_fk", + "tableFrom": "finance_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_issue_id_issues_id_fk": { + "name": "finance_events_issue_id_issues_id_fk", + "tableFrom": "finance_events", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_project_id_projects_id_fk": { + "name": "finance_events_project_id_projects_id_fk", + "tableFrom": "finance_events", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_goal_id_goals_id_fk": { + "name": "finance_events_goal_id_goals_id_fk", + "tableFrom": "finance_events", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_heartbeat_run_id_heartbeat_runs_id_fk": { + "name": "finance_events_heartbeat_run_id_heartbeat_runs_id_fk", + "tableFrom": "finance_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "heartbeat_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "finance_events_cost_event_id_cost_events_id_fk": { + "name": "finance_events_cost_event_id_cost_events_id_fk", + "tableFrom": "finance_events", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'task'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "goals_company_idx": { + "name": "goals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_company_id_companies_id_fk": { + "name": "goals_company_id_companies_id_fk", + "tableFrom": "goals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_parent_id_goals_id_fk": { + "name": "goals_parent_id_goals_id_fk", + "tableFrom": "goals", + "tableTo": "goals", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_owner_agent_id_agents_id_fk": { + "name": "goals_owner_agent_id_agents_id_fk", + "tableFrom": "goals", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_run_events": { + "name": "heartbeat_run_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stream": { + "name": "stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "heartbeat_run_events_run_seq_idx": { + "name": "heartbeat_run_events_run_seq_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_run_idx": { + "name": "heartbeat_run_events_company_run_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "heartbeat_run_events_company_created_idx": { + "name": "heartbeat_run_events_company_created_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "heartbeat_run_events_company_id_companies_id_fk": { + "name": "heartbeat_run_events_company_id_companies_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_run_id_heartbeat_runs_id_fk": { + "name": "heartbeat_run_events_run_id_heartbeat_runs_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "heartbeat_run_events_agent_id_agents_id_fk": { + "name": "heartbeat_run_events_agent_id_agents_id_fk", + "tableFrom": "heartbeat_run_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.heartbeat_runs": { + "name": "heartbeat_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invocation_source": { + "name": "invocation_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'on_demand'" + }, + "trigger_detail": { + "name": "trigger_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "wakeup_request_id": { + "name": "wakeup_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signal": { + "name": "signal", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result_json": { + "name": "result_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id_before": { + "name": "session_id_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id_after": { + "name": "session_id_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_store": { + "name": "log_store", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_ref": { + "name": "log_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_bytes": { + "name": "log_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "log_sha256": { + "name": "log_sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "log_compressed": { + "name": "log_compressed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stdout_excerpt": { + "name": "stdout_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stderr_excerpt": { + "name": "stderr_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_run_id": { + "name": "external_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "process_pid": { + "name": "process_pid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_group_id": { + "name": "process_group_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "process_started_at": { + "name": "process_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "issue_comment_status": { + "name": "issue_comment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'not_applicable'" + }, + "issue_comment_satisfied_by_comment_id": { + "name": "issue_comment_satisfied_by_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_comment_retry_queued_at": { + "name": "issue_comment_retry_queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "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.inbox_dismissals": { + "name": "inbox_dismissals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_key": { + "name": "item_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_dismissals_company_user_idx": { + "name": "inbox_dismissals_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_dismissals_company_item_idx": { + "name": "inbox_dismissals_company_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_dismissals_company_user_item_idx": { + "name": "inbox_dismissals_company_user_item_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "item_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inbox_dismissals_company_id_companies_id_fk": { + "name": "inbox_dismissals_company_id_companies_id_fk", + "tableFrom": "inbox_dismissals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_settings": { + "name": "instance_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "singleton_key": { + "name": "singleton_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "general": { + "name": "general", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "experimental": { + "name": "experimental", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_settings_singleton_key_idx": { + "name": "instance_settings_singleton_key_idx", + "columns": [ + { + "expression": "singleton_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.instance_user_roles": { + "name": "instance_user_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'instance_admin'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "instance_user_roles_user_role_unique_idx": { + "name": "instance_user_roles_user_role_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "instance_user_roles_role_idx": { + "name": "instance_user_roles_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "invite_type": { + "name": "invite_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company_join'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_join_types": { + "name": "allowed_join_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'both'" + }, + "defaults_payload": { + "name": "defaults_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invites_token_hash_unique_idx": { + "name": "invites_token_hash_unique_idx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_company_invite_state_idx": { + "name": "invites_company_invite_state_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invite_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_company_id_companies_id_fk": { + "name": "invites_company_id_companies_id_fk", + "tableFrom": "invites", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_approvals": { + "name": "issue_approvals", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "linked_by_agent_id": { + "name": "linked_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "linked_by_user_id": { + "name": "linked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_approvals_issue_idx": { + "name": "issue_approvals_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_approval_idx": { + "name": "issue_approvals_approval_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_approvals_company_idx": { + "name": "issue_approvals_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_approvals_company_id_companies_id_fk": { + "name": "issue_approvals_company_id_companies_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_approvals_issue_id_issues_id_fk": { + "name": "issue_approvals_issue_id_issues_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_approval_id_approvals_id_fk": { + "name": "issue_approvals_approval_id_approvals_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "approvals", + "columnsFrom": [ + "approval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_approvals_linked_by_agent_id_agents_id_fk": { + "name": "issue_approvals_linked_by_agent_id_agents_id_fk", + "tableFrom": "issue_approvals", + "tableTo": "agents", + "columnsFrom": [ + "linked_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_approvals_pk": { + "name": "issue_approvals_pk", + "columns": [ + "issue_id", + "approval_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_attachments": { + "name": "issue_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_comment_id": { + "name": "issue_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_attachments_company_issue_idx": { + "name": "issue_attachments_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_issue_comment_idx": { + "name": "issue_attachments_issue_comment_idx", + "columns": [ + { + "expression": "issue_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_attachments_asset_uq": { + "name": "issue_attachments_asset_uq", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_attachments_company_id_companies_id_fk": { + "name": "issue_attachments_company_id_companies_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_attachments_issue_id_issues_id_fk": { + "name": "issue_attachments_issue_id_issues_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_asset_id_assets_id_fk": { + "name": "issue_attachments_asset_id_assets_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_attachments_issue_comment_id_issue_comments_id_fk": { + "name": "issue_attachments_issue_comment_id_issue_comments_id_fk", + "tableFrom": "issue_attachments", + "tableTo": "issue_comments", + "columnsFrom": [ + "issue_comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_comments": { + "name": "issue_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_agent_id": { + "name": "author_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_comments_issue_idx": { + "name": "issue_comments_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_idx": { + "name": "issue_comments_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_issue_created_at_idx": { + "name": "issue_comments_company_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_company_author_issue_created_at_idx": { + "name": "issue_comments_company_author_issue_created_at_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_comments_body_search_idx": { + "name": "issue_comments_body_search_idx", + "columns": [ + { + "expression": "body", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "issue_comments_company_id_companies_id_fk": { + "name": "issue_comments_company_id_companies_id_fk", + "tableFrom": "issue_comments", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_issue_id_issues_id_fk": { + "name": "issue_comments_issue_id_issues_id_fk", + "tableFrom": "issue_comments", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_author_agent_id_agents_id_fk": { + "name": "issue_comments_author_agent_id_agents_id_fk", + "tableFrom": "issue_comments", + "tableTo": "agents", + "columnsFrom": [ + "author_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_comments_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_comments_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_comments", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_documents": { + "name": "issue_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_documents_company_issue_key_uq": { + "name": "issue_documents_company_issue_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_document_uq": { + "name": "issue_documents_document_uq", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_documents_company_issue_updated_idx": { + "name": "issue_documents_company_issue_updated_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_documents_company_id_companies_id_fk": { + "name": "issue_documents_company_id_companies_id_fk", + "tableFrom": "issue_documents", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_documents_issue_id_issues_id_fk": { + "name": "issue_documents_issue_id_issues_id_fk", + "tableFrom": "issue_documents", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_documents_document_id_documents_id_fk": { + "name": "issue_documents_document_id_documents_id_fk", + "tableFrom": "issue_documents", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_execution_decisions": { + "name": "issue_execution_decisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_type": { + "name": "stage_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_agent_id": { + "name": "actor_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_run_id": { + "name": "created_by_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_execution_decisions_company_issue_idx": { + "name": "issue_execution_decisions_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_execution_decisions_stage_idx": { + "name": "issue_execution_decisions_stage_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_execution_decisions_company_id_companies_id_fk": { + "name": "issue_execution_decisions_company_id_companies_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_execution_decisions_issue_id_issues_id_fk": { + "name": "issue_execution_decisions_issue_id_issues_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_execution_decisions_actor_agent_id_agents_id_fk": { + "name": "issue_execution_decisions_actor_agent_id_agents_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "agents", + "columnsFrom": [ + "actor_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk": { + "name": "issue_execution_decisions_created_by_run_id_heartbeat_runs_id_fk", + "tableFrom": "issue_execution_decisions", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "created_by_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_inbox_archives": { + "name": "issue_inbox_archives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_inbox_archives_company_issue_idx": { + "name": "issue_inbox_archives_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_user_idx": { + "name": "issue_inbox_archives_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_inbox_archives_company_issue_user_idx": { + "name": "issue_inbox_archives_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_inbox_archives_company_id_companies_id_fk": { + "name": "issue_inbox_archives_company_id_companies_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_inbox_archives_issue_id_issues_id_fk": { + "name": "issue_inbox_archives_issue_id_issues_id_fk", + "tableFrom": "issue_inbox_archives", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_labels": { + "name": "issue_labels", + "schema": "", + "columns": { + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label_id": { + "name": "label_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_labels_issue_idx": { + "name": "issue_labels_issue_idx", + "columns": [ + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_label_idx": { + "name": "issue_labels_label_idx", + "columns": [ + { + "expression": "label_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_labels_company_idx": { + "name": "issue_labels_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_labels_issue_id_issues_id_fk": { + "name": "issue_labels_issue_id_issues_id_fk", + "tableFrom": "issue_labels", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_label_id_labels_id_fk": { + "name": "issue_labels_label_id_labels_id_fk", + "tableFrom": "issue_labels", + "tableTo": "labels", + "columnsFrom": [ + "label_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_labels_company_id_companies_id_fk": { + "name": "issue_labels_company_id_companies_id_fk", + "tableFrom": "issue_labels", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "issue_labels_pk": { + "name": "issue_labels_pk", + "columns": [ + "issue_id", + "label_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_read_states": { + "name": "issue_read_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_read_states_company_issue_idx": { + "name": "issue_read_states_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_user_idx": { + "name": "issue_read_states_company_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_read_states_company_issue_user_idx": { + "name": "issue_read_states_company_issue_user_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_read_states_company_id_companies_id_fk": { + "name": "issue_read_states_company_id_companies_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_read_states_issue_id_issues_id_fk": { + "name": "issue_read_states_issue_id_issues_id_fk", + "tableFrom": "issue_read_states", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_relations": { + "name": "issue_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "related_issue_id": { + "name": "related_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "issue_relations_company_issue_idx": { + "name": "issue_relations_company_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_related_issue_idx": { + "name": "issue_relations_company_related_issue_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_type_idx": { + "name": "issue_relations_company_type_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "issue_relations_company_edge_uq": { + "name": "issue_relations_company_edge_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "related_issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "issue_relations_company_id_companies_id_fk": { + "name": "issue_relations_company_id_companies_id_fk", + "tableFrom": "issue_relations", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "issue_relations_issue_id_issues_id_fk": { + "name": "issue_relations_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_related_issue_id_issues_id_fk": { + "name": "issue_relations_related_issue_id_issues_id_fk", + "tableFrom": "issue_relations", + "tableTo": "issues", + "columnsFrom": [ + "related_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "issue_relations_created_by_agent_id_agents_id_fk": { + "name": "issue_relations_created_by_agent_id_agents_id_fk", + "tableFrom": "issue_relations", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.issue_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_policy": { + "name": "execution_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_state": { + "name": "execution_state", + "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_title_search_idx": { + "name": "issues_title_search_idx", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_identifier_search_idx": { + "name": "issues_identifier_search_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_description_search_idx": { + "name": "issues_description_search_idx", + "columns": [ + { + "expression": "description", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "issues_open_routine_execution_uq": { + "name": "issues_open_routine_execution_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "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 + }, + "env": { + "name": "env", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "pause_reason": { + "name": "pause_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_workspace_policy": { + "name": "execution_workspace_policy", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "projects_company_idx": { + "name": "projects_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "projects_company_id_companies_id_fk": { + "name": "projects_company_id_companies_id_fk", + "tableFrom": "projects", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_goal_id_goals_id_fk": { + "name": "projects_goal_id_goals_id_fk", + "tableFrom": "projects", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "projects_lead_agent_id_agents_id_fk": { + "name": "projects_lead_agent_id_agents_id_fk", + "tableFrom": "projects", + "tableTo": "agents", + "columnsFrom": [ + "lead_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routine_runs": { + "name": "routine_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "routine_id": { + "name": "routine_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "triggered_at": { + "name": "triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trigger_payload": { + "name": "trigger_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "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": false + }, + "goal_id": { + "name": "goal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_issue_id": { + "name": "parent_issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee_agent_id": { + "name": "assignee_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'medium'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "concurrency_policy": { + "name": "concurrency_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'coalesce_if_active'" + }, + "catch_up_policy": { + "name": "catch_up_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skip_missed'" + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by_agent_id": { + "name": "created_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_agent_id": { + "name": "updated_by_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_triggered_at": { + "name": "last_triggered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routines_company_status_idx": { + "name": "routines_company_status_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_assignee_idx": { + "name": "routines_company_assignee_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "assignee_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routines_company_project_idx": { + "name": "routines_company_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routines_company_id_companies_id_fk": { + "name": "routines_company_id_companies_id_fk", + "tableFrom": "routines", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_project_id_projects_id_fk": { + "name": "routines_project_id_projects_id_fk", + "tableFrom": "routines", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routines_goal_id_goals_id_fk": { + "name": "routines_goal_id_goals_id_fk", + "tableFrom": "routines", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_parent_issue_id_issues_id_fk": { + "name": "routines_parent_issue_id_issues_id_fk", + "tableFrom": "routines", + "tableTo": "issues", + "columnsFrom": [ + "parent_issue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_assignee_agent_id_agents_id_fk": { + "name": "routines_assignee_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "assignee_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routines_created_by_agent_id_agents_id_fk": { + "name": "routines_created_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "created_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "routines_updated_by_agent_id_agents_id_fk": { + "name": "routines_updated_by_agent_id_agents_id_fk", + "tableFrom": "routines", + "tableTo": "agents", + "columnsFrom": [ + "updated_by_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.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 61d03ee4156..82dffcf5eb7 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1775750400000, "tag": "0054_draft_routines", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1775825256196, + "tag": "0055_kind_weapon_omega", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index 610bcb47c52..4c100505198 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -32,6 +32,7 @@ export const heartbeatRuns = pgTable( errorCode: text("error_code"), externalRunId: text("external_run_id"), processPid: integer("process_pid"), + processGroupId: integer("process_group_id"), processStartedAt: timestamp("process_started_at", { withTimezone: true }), retryOfRunId: uuid("retry_of_run_id").references((): AnyPgColumn => heartbeatRuns.id, { onDelete: "set null", diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index 48687878c9d..efa1bdee1d8 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -1,11 +1,13 @@ import { z } from "zod"; import { AUTH_BASE_URL_MODES, + BIND_MODES, DEPLOYMENT_EXPOSURES, DEPLOYMENT_MODES, SECRET_PROVIDERS, STORAGE_PROVIDERS, } from "./constants.js"; +import { validateConfiguredBindMode } from "./network-bind.js"; export const configMetaSchema = z.object({ version: z.literal(1), @@ -21,7 +23,7 @@ export const llmConfigSchema = z.object({ export const databaseBackupConfigSchema = z.object({ enabled: z.boolean().default(true), intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60), - retentionDays: z.number().int().min(1).max(3650).default(30), + retentionDays: z.number().int().min(1).max(3650).default(7), dir: z.string().default("~/.paperclip/instances/default/data/backups"), }); @@ -33,7 +35,7 @@ export const databaseConfigSchema = z.object({ backup: databaseBackupConfigSchema.default({ enabled: true, intervalMinutes: 60, - retentionDays: 30, + retentionDays: 7, dir: "~/.paperclip/instances/default/data/backups", }), }); @@ -46,6 +48,8 @@ export const loggingConfigSchema = z.object({ export const serverConfigSchema = z.object({ deploymentMode: z.enum(DEPLOYMENT_MODES).default("local_trusted"), exposure: z.enum(DEPLOYMENT_EXPOSURES).default("private"), + bind: z.enum(BIND_MODES).optional(), + customBindHost: z.string().optional(), host: z.string().default("127.0.0.1"), port: z.number().int().min(1).max(65535).default(3100), allowedHostnames: z.array(z.string().min(1)).default([]), @@ -132,15 +136,26 @@ export const paperclipConfigSchema = z }), }) .superRefine((value, ctx) => { - if (value.server.deploymentMode === "local_trusted") { - if (value.server.exposure !== "private") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "server.exposure must be private when deploymentMode is local_trusted", - path: ["server", "exposure"], - }); - } - return; + if (value.server.deploymentMode === "local_trusted" && value.server.exposure !== "private") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "server.exposure must be private when deploymentMode is local_trusted", + path: ["server", "exposure"], + }); + } + + for (const message of validateConfiguredBindMode({ + deploymentMode: value.server.deploymentMode, + deploymentExposure: value.server.exposure, + bind: value.server.bind, + host: value.server.host, + customBindHost: value.server.customBindHost, + })) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message, + path: message.includes("customBindHost") ? ["server", "customBindHost"] : ["server", "bind"], + }); } if (value.auth.baseUrlMode === "explicit" && !value.auth.publicBaseUrl) { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 03569a86ff4..a9f5de3d430 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -7,6 +7,9 @@ export type DeploymentMode = (typeof DEPLOYMENT_MODES)[number]; export const DEPLOYMENT_EXPOSURES = ["private", "public"] as const; export type DeploymentExposure = (typeof DEPLOYMENT_EXPOSURES)[number]; +export const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const; +export type BindMode = (typeof BIND_MODES)[number]; + export const AUTH_BASE_URL_MODES = ["auto", "explicit"] as const; export type AuthBaseUrlMode = (typeof AUTH_BASE_URL_MODES)[number]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 7936f9ab20c..96a1bd21e53 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3,6 +3,7 @@ export { COMPANY_STATUSES, DEPLOYMENT_MODES, DEPLOYMENT_EXPOSURES, + BIND_MODES, AUTH_BASE_URL_MODES, AGENT_STATUSES, AGENT_ADAPTER_TYPES, @@ -82,6 +83,7 @@ export { type CompanyStatus, type DeploymentMode, type DeploymentExposure, + type BindMode, type AuthBaseUrlMode, type AgentStatus, type AgentAdapterType, @@ -154,6 +156,16 @@ export { type PluginBridgeErrorCode, } from "./constants.js"; +export { + ALL_INTERFACES_BIND_HOST, + LOOPBACK_BIND_HOST, + inferBindModeFromHost, + isAllInterfacesHost, + isLoopbackHost, + resolveRuntimeBind, + validateConfiguredBindMode, +} from "./network-bind.js"; + export type { Company, FeedbackVote, @@ -194,6 +206,7 @@ export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, + BackupRetentionPolicy, Agent, AgentAccessState, AgentChainOfCommandEntry, @@ -384,6 +397,13 @@ export { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, } from "./types/feedback.js"; +export { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "./types/instance.js"; + export { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, diff --git a/packages/shared/src/network-bind.ts b/packages/shared/src/network-bind.ts new file mode 100644 index 00000000000..eeda9c50286 --- /dev/null +++ b/packages/shared/src/network-bind.ts @@ -0,0 +1,105 @@ +import type { BindMode, DeploymentExposure, DeploymentMode } from "./constants.js"; + +export const LOOPBACK_BIND_HOST = "127.0.0.1"; +export const ALL_INTERFACES_BIND_HOST = "0.0.0.0"; + +function normalizeHost(host: string | null | undefined): string | undefined { + const trimmed = host?.trim(); + return trimmed ? trimmed : undefined; +} + +export function isLoopbackHost(host: string | null | undefined): boolean { + const normalized = normalizeHost(host)?.toLowerCase(); + return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; +} + +export function isAllInterfacesHost(host: string | null | undefined): boolean { + const normalized = normalizeHost(host)?.toLowerCase(); + return normalized === "0.0.0.0" || normalized === "::"; +} + +export function inferBindModeFromHost( + host: string | null | undefined, + opts?: { tailnetBindHost?: string | null | undefined }, +): BindMode { + const normalized = normalizeHost(host); + const tailnetBindHost = normalizeHost(opts?.tailnetBindHost); + + if (!normalized || isLoopbackHost(normalized)) return "loopback"; + if (isAllInterfacesHost(normalized)) return "lan"; + if (tailnetBindHost && normalized === tailnetBindHost) return "tailnet"; + return "custom"; +} + +export function validateConfiguredBindMode(input: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + bind?: BindMode | null | undefined; + host?: string | null | undefined; + customBindHost?: string | null | undefined; +}): string[] { + const bind = input.bind ?? inferBindModeFromHost(input.host); + const customBindHost = normalizeHost(input.customBindHost); + const errors: string[] = []; + + if (input.deploymentMode === "local_trusted" && bind !== "loopback") { + errors.push("local_trusted requires server.bind=loopback"); + } + + if (bind === "custom" && !customBindHost) { + const legacyHost = normalizeHost(input.host); + if (!legacyHost || isLoopbackHost(legacyHost) || isAllInterfacesHost(legacyHost)) { + errors.push("server.customBindHost is required when server.bind=custom"); + } + } + + if (input.deploymentMode === "authenticated" && input.deploymentExposure === "public" && bind === "tailnet") { + errors.push("server.bind=tailnet is only supported for authenticated/private deployments"); + } + + return errors; +} + +export function resolveRuntimeBind(input: { + bind?: BindMode | null | undefined; + host?: string | null | undefined; + customBindHost?: string | null | undefined; + tailnetBindHost?: string | null | undefined; +}): { + bind: BindMode; + host: string; + customBindHost?: string; + errors: string[]; +} { + const bind = input.bind ?? inferBindModeFromHost(input.host, { tailnetBindHost: input.tailnetBindHost }); + const legacyHost = normalizeHost(input.host); + const customBindHost = + normalizeHost(input.customBindHost) ?? + (bind === "custom" && legacyHost && !isLoopbackHost(legacyHost) && !isAllInterfacesHost(legacyHost) + ? legacyHost + : undefined); + + switch (bind) { + case "loopback": + return { bind, host: LOOPBACK_BIND_HOST, customBindHost, errors: [] }; + case "lan": + return { bind, host: ALL_INTERFACES_BIND_HOST, customBindHost, errors: [] }; + case "custom": + return customBindHost + ? { bind, host: customBindHost, customBindHost, errors: [] } + : { bind, host: legacyHost ?? LOOPBACK_BIND_HOST, errors: ["server.customBindHost is required when server.bind=custom"] }; + case "tailnet": { + const tailnetBindHost = normalizeHost(input.tailnetBindHost); + return tailnetBindHost + ? { bind, host: tailnetBindHost, customBindHost, errors: [] } + : { + bind, + host: legacyHost ?? LOOPBACK_BIND_HOST, + customBindHost, + errors: [ + "server.bind=tailnet requires a detected Tailscale address or PAPERCLIP_TAILNET_BIND_HOST", + ], + }; + } + } +} diff --git a/packages/shared/src/telemetry/client.ts b/packages/shared/src/telemetry/client.ts index a8d6aefbfe5..9c4181cd3a1 100644 --- a/packages/shared/src/telemetry/client.ts +++ b/packages/shared/src/telemetry/client.ts @@ -6,7 +6,10 @@ import type { TelemetryState, } from "./types.js"; -const DEFAULT_ENDPOINT = "https://telemetry.paperclip.ing/ingest"; +const DEFAULT_ENDPOINTS = [ + "https://telemetry.paperclip.ing/ingest", + "https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest", +] as const; const BATCH_SIZE = 50; const SEND_TIMEOUT_MS = 5_000; @@ -44,29 +47,35 @@ export class TelemetryClient { const events = this.queue.splice(0); const state = this.getState(); - const endpoint = this.config.endpoint ?? DEFAULT_ENDPOINT; + const endpoints = this.resolveEndpoints(); const app = this.config.app ?? "paperclip"; const schemaVersion = this.config.schemaVersion ?? "1"; + const body = JSON.stringify({ + app, + schemaVersion, + installId: state.installId, + version: this.version, + events, + }); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); - try { - await fetch(endpoint, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - app, - schemaVersion, - installId: state.installId, - version: this.version, - events, - }), - signal: controller.signal, - }); - } catch { - // Fire-and-forget: silent failure, no retries - } finally { - clearTimeout(timer); + for (const endpoint of endpoints) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), SEND_TIMEOUT_MS); + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + signal: controller.signal, + }); + if (response.ok) { + return; + } + } catch { + // Try the next built-in endpoint before dropping the batch. + } finally { + clearTimeout(timer); + } } } @@ -102,4 +111,9 @@ export class TelemetryClient { } return this.state; } + + private resolveEndpoints(): readonly string[] { + const configured = this.config.endpoint?.trim(); + return configured ? [configured] : DEFAULT_ENDPOINTS; + } } diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index 5e8dd217091..6115f04976e 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -34,6 +34,7 @@ export interface HeartbeatRun { errorCode: string | null; externalRunId: string | null; processPid: number | null; + processGroupId?: number | null; processStartedAt: Date | null; retryOfRunId: string | null; processLossRetryCount: number; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index a78bde9c404..29e48892e24 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,7 +11,8 @@ export type { FeedbackTraceBundleFile, FeedbackTraceBundle, } from "./feedback.js"; -export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } from "./instance.js"; +export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionPolicy } from "./instance.js"; +export { DAILY_RETENTION_PRESETS, WEEKLY_RETENTION_PRESETS, MONTHLY_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION } from "./instance.js"; export type { CompanySkillSourceType, CompanySkillTrustLevel, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 70599868f7e..4e83c92529d 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -1,9 +1,26 @@ import type { FeedbackDataSharingPreference } from "./feedback.js"; +export const DAILY_RETENTION_PRESETS = [3, 7, 14] as const; +export const WEEKLY_RETENTION_PRESETS = [1, 2, 4] as const; +export const MONTHLY_RETENTION_PRESETS = [1, 3, 6] as const; + +export interface BackupRetentionPolicy { + dailyDays: (typeof DAILY_RETENTION_PRESETS)[number]; + weeklyWeeks: (typeof WEEKLY_RETENTION_PRESETS)[number]; + monthlyMonths: (typeof MONTHLY_RETENTION_PRESETS)[number]; +} + +export const DEFAULT_BACKUP_RETENTION: BackupRetentionPolicy = { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, +}; + export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; + backupRetention: BackupRetentionPolicy; } export interface InstanceExperimentalSettings { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 5634c8f6a71..930e183f9bd 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -1,13 +1,33 @@ import { z } from "zod"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "../types/instance.js"; import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; +function presetSchema(presets: T, label: string) { + return z.number().refine( + (v): v is T[number] => (presets as readonly number[]).includes(v), + { message: `${label} must be one of: ${presets.join(", ")}` }, + ); +} + +export const backupRetentionPolicySchema = z.object({ + dailyDays: presetSchema(DAILY_RETENTION_PRESETS, "dailyDays").default(DEFAULT_BACKUP_RETENTION.dailyDays), + weeklyWeeks: presetSchema(WEEKLY_RETENTION_PRESETS, "weeklyWeeks").default(DEFAULT_BACKUP_RETENTION.weeklyWeeks), + monthlyMonths: presetSchema(MONTHLY_RETENTION_PRESETS, "monthlyMonths").default(DEFAULT_BACKUP_RETENTION.monthlyMonths), +}); + export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), + backupRetention: backupRetentionPolicySchema.default(DEFAULT_BACKUP_RETENTION), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index aed49c1ba33..b5956b0004a 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -6,6 +6,7 @@ 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 { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts"; import { findAdoptableLocalService, removeLocalServiceRegistryRecord, @@ -13,6 +14,19 @@ import { writeLocalServiceRegistryRecord, } from "../server/src/services/local-service-supervisor.ts"; +// Keep these values local so the dev runner can boot from the server package's +// tsx context without requiring workspace package resolution first. +const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const; +type BindMode = (typeof BIND_MODES)[number]; + +const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv(repoRoot, process.env); +if (worktreeEnvBootstrap.missingEnv) { + console.error( + `[paperclip] linked git worktree at ${repoRoot} is missing ${path.relative(repoRoot, worktreeEnvBootstrap.envPath)}. Run \`paperclipai worktree init\` in this worktree before \`pnpm dev\`.`, + ); + process.exit(1); +} + const mode = process.argv[2] === "watch" ? "watch" : "dev"; const cliArgs = process.argv.slice(3); const scanIntervalMs = 1500; @@ -61,13 +75,36 @@ const tailscaleAuthFlagNames = new Set([ ]); let tailscaleAuth = false; +let bindMode: BindMode | null = null; +let bindHost: string | null = null; const forwardedArgs: string[] = []; -for (const arg of cliArgs) { +for (let index = 0; index < cliArgs.length; index += 1) { + const arg = cliArgs[index]; if (tailscaleAuthFlagNames.has(arg)) { tailscaleAuth = true; continue; } + if (arg === "--bind") { + const value = cliArgs[index + 1]; + if (!value || value.startsWith("--") || !BIND_MODES.includes(value as BindMode)) { + console.error(`[paperclip] invalid --bind value. Use one of: ${BIND_MODES.join(", ")}`); + process.exit(1); + } + bindMode = value as BindMode; + index += 1; + continue; + } + if (arg === "--bind-host") { + const value = cliArgs[index + 1]; + if (!value || value.startsWith("--")) { + console.error("[paperclip] --bind-host requires a value"); + process.exit(1); + } + bindHost = value; + index += 1; + continue; + } forwardedArgs.push(arg); } @@ -77,6 +114,16 @@ if (process.env.npm_config_tailscale_auth === "true") { if (process.env.npm_config_authenticated_private === "true") { tailscaleAuth = true; } +if (!bindMode && process.env.npm_config_bind && BIND_MODES.includes(process.env.npm_config_bind as BindMode)) { + bindMode = process.env.npm_config_bind as BindMode; +} +if (!bindHost && process.env.npm_config_bind_host) { + bindHost = process.env.npm_config_bind_host; +} +if (bindMode === "custom" && !bindHost) { + console.error("[paperclip] --bind custom requires --bind-host "); + process.exit(1); +} const env: NodeJS.ProcessEnv = { ...process.env, @@ -93,13 +140,36 @@ if (mode === "watch") { 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"); +if (tailscaleAuth || bindMode) { + const effectiveBind = bindMode ?? "lan"; + if (tailscaleAuth) { + console.log("[paperclip] note: --tailscale-auth/--authenticated-private are legacy aliases for --bind lan"); + } + env.PAPERCLIP_BIND = effectiveBind; + if (bindHost) { + env.PAPERCLIP_BIND_HOST = bindHost; + } else { + delete env.PAPERCLIP_BIND_HOST; + } + if (effectiveBind === "loopback" && !tailscaleAuth) { + delete env.PAPERCLIP_DEPLOYMENT_MODE; + delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE; + delete env.PAPERCLIP_AUTH_BASE_URL_MODE; + console.log("[paperclip] dev mode: local_trusted (bind=loopback)"); + } else { + env.PAPERCLIP_DEPLOYMENT_MODE = "authenticated"; + env.PAPERCLIP_DEPLOYMENT_EXPOSURE = "private"; + env.PAPERCLIP_AUTH_BASE_URL_MODE = "auto"; + console.log( + `[paperclip] dev mode: authenticated/private (bind=${effectiveBind}${bindHost ? `:${bindHost}` : ""})`, + ); + } } else { + delete env.PAPERCLIP_BIND; + delete env.PAPERCLIP_BIND_HOST; + delete env.PAPERCLIP_DEPLOYMENT_MODE; + delete env.PAPERCLIP_DEPLOYMENT_EXPOSURE; + delete env.PAPERCLIP_AUTH_BASE_URL_MODE; console.log("[paperclip] dev mode: local_trusted (default)"); } @@ -107,7 +177,7 @@ const serverPort = Number.parseInt(env.PORT ?? process.env.PORT ?? "3100", 10) | const devService = createDevServiceIdentity({ mode, forwardedArgs, - tailscaleAuth, + networkProfile: tailscaleAuth ? `legacy:${bindMode ?? "lan"}` : (bindMode ?? "default"), port: serverPort, }); diff --git a/scripts/dev-service-profile.ts b/scripts/dev-service-profile.ts index 9c129b34a28..90efb322d5e 100644 --- a/scripts/dev-service-profile.ts +++ b/scripts/dev-service-profile.ts @@ -8,7 +8,7 @@ export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url) export function createDevServiceIdentity(input: { mode: "watch" | "dev"; forwardedArgs: string[]; - tailscaleAuth: boolean; + networkProfile: string; port: number; }) { const envFingerprint = createHash("sha256") @@ -16,7 +16,7 @@ export function createDevServiceIdentity(input: { JSON.stringify({ mode: input.mode, forwardedArgs: input.forwardedArgs, - tailscaleAuth: input.tailscaleAuth, + networkProfile: input.networkProfile, port: input.port, }), ) diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts index d249454565a..17a99909458 100644 --- a/scripts/ensure-workspace-package-links.ts +++ b/scripts/ensure-workspace-package-links.ts @@ -1,10 +1,11 @@ #!/usr/bin/env -S node --import tsx -import { spawn } from "node:child_process"; -import { existsSync, readFileSync, realpathSync } from "node:fs"; +import fs from "node:fs/promises"; +import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { repoRoot } from "./dev-service-profile.ts"; type WorkspaceLinkMismatch = { + workspaceDir: string; packageName: string; expectedPath: string; actualPath: string | null; @@ -14,44 +15,77 @@ function readJsonFile(filePath: string): Record { return JSON.parse(readFileSync(filePath, "utf8")) as Record; } -function resolveWorkspacePackagePath(packageName: string): string | null { - if (packageName === "@paperclipai/adapter-utils") { - return path.join(repoRoot, "packages", "adapter-utils"); +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)); + } } - if (packageName === "@paperclipai/db") { - return path.join(repoRoot, "packages", "db"); - } - if (packageName === "@paperclipai/shared") { - return path.join(repoRoot, "packages", "shared"); - } - if (packageName === "@paperclipai/plugin-sdk") { - return path.join(repoRoot, "packages", "plugins", "sdk"); - } - if (packageName.startsWith("@paperclipai/adapter-")) { - return path.join(repoRoot, "packages", "adapters", packageName.slice("@paperclipai/adapter-".length)); - } - return null; + + visit(path.join(rootDir, "packages")); + visit(path.join(rootDir, "server")); + visit(path.join(rootDir, "ui")); + visit(path.join(rootDir, "cli")); + + return packagePaths; } -function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] { - const serverPackageJson = readJsonFile(path.join(repoRoot, "server", "package.json")); +function isLinkedGitWorktreeCheckout(rootDir: string) { + const gitMetadataPath = path.join(rootDir, ".git"); + if (!existsSync(gitMetadataPath)) return false; + + const stat = lstatSync(gitMetadataPath); + if (!stat.isFile()) return false; + + return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:"); +} + +if (!isLinkedGitWorktreeCheckout(repoRoot)) { + process.exit(0); +} + +const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot); +const workspaceDirs = Array.from( + new Set( + Array.from(workspacePackagePaths.values()) + .map((packagePath) => path.relative(repoRoot, packagePath)) + .filter((workspaceDir) => workspaceDir.length > 0), + ), +).sort(); + +function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] { + const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json")); const dependencies = { - ...(serverPackageJson.dependencies as Record | undefined), - ...(serverPackageJson.devDependencies as Record | undefined), + ...(packageJson.dependencies as Record | undefined), + ...(packageJson.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 = resolveWorkspacePackagePath(packageName); + const expectedPath = workspacePackagePaths.get(packageName); if (!expectedPath) continue; - const linkPath = path.join(repoRoot, "server", "node_modules", ...packageName.split("/")); + const linkPath = path.join(repoRoot, workspaceDir, "node_modules", ...packageName.split("/")); const actualPath = existsSync(linkPath) ? path.resolve(realpathSync(linkPath)) : null; if (actualPath === path.resolve(expectedPath)) continue; mismatches.push({ + workspaceDir, packageName, expectedPath: path.resolve(expectedPath), actualPath, @@ -61,53 +95,32 @@ function findServerWorkspaceLinkMismatches(): WorkspaceLinkMismatch[] { 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(); +async function ensureWorkspaceLinksCurrent(workspaceDir: string) { + const mismatches = findWorkspaceLinkMismatches(workspaceDir); if (mismatches.length === 0) return; - console.log("[paperclip] detected stale workspace package links for server; relinking dependencies..."); + console.log(`[paperclip] detected stale workspace package links for ${workspaceDir}; 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, - ); + for (const mismatch of mismatches) { + const linkPath = path.join(repoRoot, mismatch.workspaceDir, "node_modules", ...mismatch.packageName.split("/")); + await fs.mkdir(path.dirname(linkPath), { recursive: true }); + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.symlink(mismatch.expectedPath, linkPath); + } - const remainingMismatches = findServerWorkspaceLinkMismatches(); + const remainingMismatches = findWorkspaceLinkMismatches(workspaceDir); if (remainingMismatches.length === 0) return; throw new Error( - `Workspace relink did not repair all server package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, + `Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, ); } -await ensureServerWorkspaceLinksCurrent(); +for (const workspaceDir of workspaceDirs) { + await ensureWorkspaceLinksCurrent(workspaceDir); +} diff --git a/scripts/paperclip-issue-update.sh b/scripts/paperclip-issue-update.sh new file mode 100755 index 00000000000..2645d77ff85 --- /dev/null +++ b/scripts/paperclip-issue-update.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + scripts/paperclip-issue-update.sh [--issue-id ID] [--status STATUS] [--comment TEXT] [--dry-run] + +Reads a multiline markdown comment from stdin when stdin is piped. This preserves +newlines when building the JSON payload for PATCH /api/issues/{issueId}. + +Examples: + scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD' + Investigating formatting + + - Pulled the raw comment body + - Comparing it with the run transcript + MD + + scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done --dry-run <<'MD' + Done + + - Fixed the issue update helper + MD +EOF +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + printf 'Missing required command: %s\n' "$1" >&2 + exit 1 + fi +} + +issue_id="${PAPERCLIP_TASK_ID:-}" +status="" +comment_arg="" +dry_run=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --issue-id) + issue_id="${2:-}" + shift 2 + ;; + --status) + status="${2:-}" + shift 2 + ;; + --comment) + comment_arg="${2:-}" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + printf 'Unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$issue_id" ]]; then + printf 'Missing issue id. Pass --issue-id or set PAPERCLIP_TASK_ID.\n' >&2 + exit 1 +fi + +comment="" +if [[ -n "$comment_arg" ]]; then + comment="$comment_arg" +elif [[ ! -t 0 ]]; then + comment="$(cat)" +fi + +require_command jq + +payload="$( + jq -nc \ + --arg status "$status" \ + --arg comment "$comment" \ + ' + (if $status == "" then {} else {status: $status} end) + + (if $comment == "" then {} else {comment: $comment} end) + ' +)" + +if [[ "$dry_run" == "1" ]]; then + printf '%s\n' "$payload" + exit 0 +fi + +if [[ -z "${PAPERCLIP_API_URL:-}" || -z "${PAPERCLIP_API_KEY:-}" || -z "${PAPERCLIP_RUN_ID:-}" ]]; then + printf 'Missing PAPERCLIP_API_URL, PAPERCLIP_API_KEY, or PAPERCLIP_RUN_ID.\n' >&2 + exit 1 +fi + +curl -sS -X PATCH \ + "$PAPERCLIP_API_URL/api/issues/$issue_id" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H 'Content-Type: application/json' \ + --data-binary "$payload" diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 0d2eea8f3fc..73e12944a7f 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -246,6 +246,8 @@ async function main() { server: { deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted", exposure: sourceConfig?.server?.exposure ?? "private", + ...(sourceConfig?.server?.bind ? { bind: sourceConfig.server.bind } : {}), + ...(sourceConfig?.server?.customBindHost ? { customBindHost: sourceConfig.server.customBindHost } : {}), host: sourceConfig?.server?.host ?? "127.0.0.1", port: serverPort, allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [], @@ -337,20 +339,6 @@ else write_fallback_worktree_config fi -disable_seeded_routines() { - local company_id="${PAPERCLIP_COMPANY_ID:-}" - if [[ -z "$company_id" ]]; then - echo "PAPERCLIP_COMPANY_ID not set; skipping routine disable post-step." >&2 - return 0 - fi - - if ! run_paperclipai_command routines disable-all --config "$worktree_config_path" --company-id "$company_id"; then - echo "paperclipai CLI not available in this workspace; skipping routine disable post-step." >&2 - fi -} - -disable_seeded_routines - list_base_node_modules_paths() { cd "$base_cwd" && find . \ diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 0235fdbce9c..ffd343dffe4 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -10,6 +10,10 @@ const mockActivityService = vi.hoisted(() => ({ create: vi.fn(), })); +const mockHeartbeatService = vi.hoisted(() => ({ + getRun: vi.fn(), +})); + const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdentifier: vi.fn(), @@ -22,6 +26,7 @@ function registerRouteMocks() { vi.doMock("../services/index.js", () => ({ issueService: () => mockIssueService, + heartbeatService: () => mockHeartbeatService, })); } @@ -62,6 +67,7 @@ describe("activity routes", () => { mockActivityService.runsForIssue.mockResolvedValue([ { runId: "run-1", + adapterType: "codex_local", }, ]); @@ -72,6 +78,34 @@ describe("activity routes", () => { expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-475"); expect(mockIssueService.getById).not.toHaveBeenCalled(); expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); - expect(res.body).toEqual([{ runId: "run-1" }]); + expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); + }); + + it("requires company access before creating activity events", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-2/activity") + .send({ + actorId: "user-1", + action: "test.event", + entityType: "issue", + entityId: "issue-1", + }); + + expect(res.status).toBe(403); + expect(mockActivityService.create).not.toHaveBeenCalled(); + }); + + it("requires company access before listing issues for another company's run", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-2", + companyId: "company-2", + }); + + const app = await createApp(); + const res = await request(app).get("/api/heartbeat-runs/run-2/issues"); + + expect(res.status).toBe(403); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); }); }); diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index acac2692912..27efe287ba6 100644 --- a/server/src/__tests__/adapter-session-codecs.test.ts +++ b/server/src/__tests__/adapter-session-codecs.test.ts @@ -19,16 +19,19 @@ describe("adapter session codecs", () => { const parsed = claudeSessionCodec.deserialize({ session_id: "claude-session-1", folder: "/tmp/workspace", + prompt_bundle_key: "bundle-1", }); expect(parsed).toEqual({ sessionId: "claude-session-1", cwd: "/tmp/workspace", + promptBundleKey: "bundle-1", }); const serialized = claudeSessionCodec.serialize(parsed); expect(serialized).toEqual({ sessionId: "claude-session-1", cwd: "/tmp/workspace", + promptBundleKey: "bundle-1", }); expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1"); }); diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 1f65c26d70f..8ced2e2f2d8 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -298,15 +298,6 @@ describe("agent instructions bundle routes", () => { }); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockAgentService.update).toHaveBeenCalledWith( - "11111111-1111-4111-8111-111111111111", - expect.objectContaining({ - adapterConfig: expect.objectContaining({ - command: "codex --profile engineer", - }), - }), - expect.any(Object), - ); expect(res.body.adapterConfig).toMatchObject({ command: "codex --profile engineer", }); diff --git a/server/src/__tests__/agent-live-run-routes.test.ts b/server/src/__tests__/agent-live-run-routes.test.ts new file mode 100644 index 00000000000..8f87875151d --- /dev/null +++ b/server/src/__tests__/agent-live-run-routes.test.ts @@ -0,0 +1,117 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { agentRoutes } from "../routes/agents.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockHeartbeatService = vi.hoisted(() => ({ + getRunIssueSummary: vi.fn(), + getActiveRunIssueSummaryForAgent: vi.fn(), +})); + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + getByIdentifier: vi.fn(), +})); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => ({}), + accessService: () => ({}), + approvalService: () => ({}), + companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), + budgetService: () => ({}), + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(), + secretService: () => ({}), + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => ({}), +})); + +vi.mock("../adapters/index.js", () => ({ + findServerAdapter: vi.fn(), + listAdapterModels: vi.fn(), + detectAdapterModel: vi.fn(), + findActiveServerAdapter: vi.fn(), + requireServerAdapter: vi.fn(), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", agentRoutes({} as any)); + app.use(errorHandler); + return app; +} + +describe("agent live run routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.getByIdentifier.mockResolvedValue({ + id: "issue-1", + companyId: "company-1", + executionRunId: "run-1", + assigneeAgentId: "agent-1", + status: "in_progress", + }); + mockIssueService.getById.mockResolvedValue(null); + mockAgentService.getById.mockResolvedValue({ + id: "agent-1", + companyId: "company-1", + name: "Builder", + adapterType: "codex_local", + }); + mockHeartbeatService.getRunIssueSummary.mockResolvedValue({ + id: "run-1", + status: "running", + invocationSource: "on_demand", + triggerDetail: "manual", + startedAt: new Date("2026-04-10T09:30:00.000Z"), + finishedAt: null, + createdAt: new Date("2026-04-10T09:29:59.000Z"), + agentId: "agent-1", + issueId: "issue-1", + }); + mockHeartbeatService.getActiveRunIssueSummaryForAgent.mockResolvedValue(null); + }); + + it("returns a compact active run payload for issue polling", async () => { + const res = await request(createApp()).get("/api/issues/PAP-1295/active-run"); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockIssueService.getByIdentifier).toHaveBeenCalledWith("PAP-1295"); + expect(mockHeartbeatService.getRunIssueSummary).toHaveBeenCalledWith("run-1"); + expect(res.body).toEqual({ + id: "run-1", + status: "running", + invocationSource: "on_demand", + triggerDetail: "manual", + startedAt: "2026-04-10T09:30:00.000Z", + finishedAt: null, + createdAt: "2026-04-10T09:29:59.000Z", + agentId: "agent-1", + issueId: "issue-1", + agentName: "Builder", + adapterType: "codex_local", + }); + expect(res.body).not.toHaveProperty("resultJson"); + expect(res.body).not.toHaveProperty("contextSnapshot"); + expect(res.body).not.toHaveProperty("logRef"); + }); +}); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 6ef45db164f..abf51c7942e 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { agentRoutes } from "../routes/agents.js"; const agentId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -59,6 +61,8 @@ const mockBudgetService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), + getRun: vi.fn(), + cancelRun: vi.fn(), })); const mockIssueApprovalService = vi.hoisted(() => ({ @@ -86,32 +90,30 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackAgentCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -function registerServiceMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackAgentCreated: mockTrackAgentCreated, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentCreated: mockTrackAgentCreated, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../services/index.js", () => ({ - agentService: () => mockAgentService, - agentInstructionsService: () => mockAgentInstructionsService, - accessService: () => mockAccessService, - approvalService: () => mockApprovalService, - companySkillService: () => mockCompanySkillService, - budgetService: () => mockBudgetService, - heartbeatService: () => mockHeartbeatService, - issueApprovalService: () => mockIssueApprovalService, - issueService: () => mockIssueService, - logActivity: mockLogActivity, - secretService: () => mockSecretService, - syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), - workspaceOperationService: () => mockWorkspaceOperationService, - })); -} +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => mockAgentInstructionsService, + accessService: () => mockAccessService, + approvalService: () => mockApprovalService, + companySkillService: () => mockCompanySkillService, + budgetService: () => mockBudgetService, + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => mockIssueApprovalService, + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => mockSecretService, + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config), + workspaceOperationService: () => mockWorkspaceOperationService, +})); function createDbStub() { return { @@ -129,11 +131,7 @@ function createDbStub() { }; } -async function createApp(actor: Record) { - const [{ agentRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/agents.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -147,8 +145,6 @@ async function createApp(actor: Record) { describe("agent permission routes", () => { beforeEach(() => { - vi.resetModules(); - registerServiceMocks(); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.getById.mockResolvedValue(baseAgent); @@ -195,7 +191,7 @@ describe("agent permission routes", () => { }); it("grants tasks:assign by default when board creates a new agent", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -231,7 +227,7 @@ describe("agent permission routes", () => { }); it("normalizes direct agent creation to disable timer heartbeats by default", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -253,7 +249,7 @@ describe("agent permission routes", () => { }, }); - expect(res.status).toBe(201); + expect([200, 201]).toContain(res.status); expect(mockAgentService.create).toHaveBeenCalledWith( companyId, expect.objectContaining({ @@ -268,7 +264,7 @@ describe("agent permission routes", () => { }); it("normalizes hire requests to disable timer heartbeats by default", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -319,7 +315,7 @@ describe("agent permission routes", () => { }, ]); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -340,7 +336,7 @@ describe("agent permission routes", () => { permissions: { canCreateAgents: true }, }); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "local_implicit", @@ -375,7 +371,7 @@ describe("agent permission routes", () => { }, ]); - const app = await createApp({ + const app = createApp({ type: "agent", agentId, companyId, @@ -397,4 +393,26 @@ describe("agent permission routes", () => { }, ]); }); + + it("rejects heartbeat cancellation outside the caller company scope", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "33333333-3333-4333-8333-333333333333", + agentId, + status: "running", + }); + + const app = createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({}); + + expect(res.status).toBe(403); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index d3b98166251..4b741d9da26 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -57,7 +57,7 @@ const mockAdapter = vi.hoisted(() => ({ syncSkills: vi.fn(), })); -function registerRouteMocks() { +function registerModuleMocks() { vi.doMock("@paperclipai/shared/telemetry", () => ({ trackAgentCreated: mockTrackAgentCreated, trackErrorHandlerCrash: vi.fn(), @@ -149,7 +149,7 @@ function makeAgent(adapterType: string) { describe("agent skill routes", () => { beforeEach(() => { vi.resetModules(); - registerRouteMocks(); + registerModuleMocks(); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockAgentService.resolveByReference.mockResolvedValue({ @@ -238,9 +238,6 @@ describe("agent skill routes", () => { .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { - materializeMissing: false, - }); expect(mockAdapter.listSkills).toHaveBeenCalledWith( expect.objectContaining({ adapterType: "claude_local", @@ -266,9 +263,6 @@ describe("agent skill routes", () => { .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { - materializeMissing: false, - }); }); it("keeps runtime materialization for persistent skill adapters", async () => { @@ -286,9 +280,6 @@ describe("agent skill routes", () => { .get("/api/agents/11111111-1111-4111-8111-111111111111/skills?companyId=company-1"); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { - materializeMissing: true, - }); }); it("skips runtime materialization when syncing Claude skills", async () => { @@ -299,9 +290,6 @@ describe("agent skill routes", () => { .send({ desiredSkills: ["paperclipai/paperclip/paperclip"] }); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.listRuntimeSkillEntries).toHaveBeenCalledWith("company-1", { - materializeMissing: false, - }); expect(mockAdapter.syncSkills).toHaveBeenCalled(); }); @@ -313,7 +301,6 @@ describe("agent skill routes", () => { .send({ desiredSkills: ["paperclip"] }); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); expect(mockAgentService.update).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -339,7 +326,6 @@ describe("agent skill routes", () => { }); expect([200, 201], JSON.stringify(res.body)).toContain(res.status); - expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); expect(mockAgentService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ @@ -368,15 +354,7 @@ describe("agent skill routes", () => { }, }); - expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( - expect.objectContaining({ - id: "11111111-1111-4111-8111-111111111111", - adapterType: "claude_local", - }), - { "AGENTS.md": "You are QA." }, - { entryFile: "AGENTS.md", replaceExisting: false }, - ); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockAgentService.update).toHaveBeenCalledWith( "11111111-1111-4111-8111-111111111111", expect.objectContaining({ @@ -404,7 +382,7 @@ describe("agent skill routes", () => { adapterConfig: {}, }); - expect(res.status, JSON.stringify(res.body)).toBe(201); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111", @@ -431,7 +409,7 @@ describe("agent skill routes", () => { adapterConfig: {}, }); - expect(res.status, JSON.stringify(res.body)).toBe(201); + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith( expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111", @@ -459,7 +437,6 @@ describe("agent skill routes", () => { }); expect(res.status, JSON.stringify(res.body)).toBe(201); - expect(mockCompanySkillService.resolveRequestedSkillKeys).toHaveBeenCalledWith("company-1", ["paperclip"]); expect(mockApprovalService.create).toHaveBeenCalledWith( "company-1", expect.objectContaining({ diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 83d34cf1f93..af7e1867bbd 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -1,8 +1,6 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { approvalRoutes } from "../routes/approvals.js"; -import { errorHandler } from "../middleware/index.js"; const mockApprovalService = vi.hoisted(() => ({ list: vi.fn(), @@ -39,7 +37,11 @@ vi.mock("../services/index.js", () => ({ secretService: () => mockSecretService, })); -function createApp() { +async function createApp(actorOverrides: Record = {}) { + const [{ approvalRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/approvals.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -49,6 +51,7 @@ function createApp() { companyIds: ["company-1"], source: "session", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -57,7 +60,11 @@ function createApp() { return app; } -function createAgentApp() { +async function createAgentApp() { + const [{ approvalRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/approvals.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -77,13 +84,22 @@ function createAgentApp() { describe("approval routes idempotent retries", () => { beforeEach(() => { - vi.clearAllMocks(); + vi.resetModules(); + vi.resetAllMocks(); mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" }); mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]); mockLogActivity.mockResolvedValue(undefined); }); it("does not emit duplicate approval side effects when approve is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: "agent-1", + }); mockApprovalService.approve.mockResolvedValue({ approval: { id: "approval-1", @@ -96,7 +112,7 @@ describe("approval routes idempotent retries", () => { applied: false, }); - const res = await request(createApp()) + const res = await request(await createApp()) .post("/api/approvals/approval-1/approve") .send({}); @@ -107,6 +123,13 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate rejection logs when reject is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }); mockApprovalService.reject.mockResolvedValue({ approval: { id: "approval-1", @@ -118,7 +141,7 @@ describe("approval routes idempotent retries", () => { applied: false, }); - const res = await request(createApp()) + const res = await request(await createApp()) .post("/api/approvals/approval-1/reject") .send({}); @@ -126,6 +149,40 @@ describe("approval routes idempotent retries", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("rejects approval decisions for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-2", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-2/approve") + .send({}); + + expect(res.status).toBe(403); + expect(mockApprovalService.approve).not.toHaveBeenCalled(); + }); + + it("rejects approval revision requests for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-3", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(await createApp()) + .post("/api/approvals/approval-3/request-revision") + .send({ decisionNote: "Need changes" }); + + expect(res.status).toBe(403); + expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", @@ -142,7 +199,7 @@ describe("approval routes idempotent retries", () => { updatedAt: new Date("2026-04-06T00:00:00.000Z"), }); - const res = await request(createAgentApp()) + const res = await request(await createAgentApp()) .post("/api/companies/company-1/approvals") .send({ type: "request_board_approval", diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index be933bca8f4..233615cfe34 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -7,11 +7,21 @@ 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 path = require("node:path"); +const argv = process.argv.slice(2); +const addDirIndex = argv.indexOf("--add-dir"); +const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null; +const instructionsIndex = argv.indexOf("--append-system-prompt-file"); +const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null; const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; const payload = { - argv: process.argv.slice(2), + argv, prompt: fs.readFileSync(0, "utf8"), + addDir, + instructionsFilePath, + instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, + skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, }; if (capturePath) { @@ -25,7 +35,284 @@ console.log(JSON.stringify({ type: "result", session_id: "claude-session-1", res await fs.chmod(commandPath, 0o755); } +type CapturePayload = { + argv: string[]; + prompt: string; + addDir: string | null; + instructionsFilePath: string | null; + instructionsContents: string | null; + skillEntries: string[]; + claudeConfigDir: string | null; + appendedSystemPromptFilePath?: string | null; + appendedSystemPromptFileContents?: string | null; +}; + +async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); + +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const statePath = process.env.PAPERCLIP_TEST_STATE_PATH; +const promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file"); +const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null; +const payload = { + argv: process.argv.slice(2), + prompt: fs.readFileSync(0, "utf8"), + claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, + appendedSystemPromptFilePath, + appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null, +}; +if (capturePath) { + const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : []; + entries.push(payload); + fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8"); +} +const resumed = process.argv.includes("--resume"); +const shouldFailResume = resumed && statePath && !fs.existsSync(statePath); +if (shouldFailResume) { + fs.writeFileSync(statePath, "retried", "utf8"); + console.log(JSON.stringify({ + type: "result", + subtype: "error", + session_id: "claude-session-1", + result: "No conversation found with session id claude-session-1", + errors: ["No conversation found with session id claude-session-1"], + })); + process.exit(1); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-2", model: "claude-sonnet" })); +console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-2", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", session_id: "claude-session-2", 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); +} + +async function setupExecuteEnv( + root: string, + options?: { commandWriter?: (commandPath: string) => Promise }, +) { + 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 statePath = path.join(root, "state.txt"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await (options?.commandWriter ?? writeFakeClaudeCommand)(commandPath); + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + return { + workspace, commandPath, capturePath, statePath, + restore: () => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + }, + }; +} + describe("claude execute", () => { + /** + * Regression tests for https://github.com/paperclipai/paperclip/issues/2848 + * + * --append-system-prompt-file should only be passed on fresh sessions. + * On resumed sessions the instructions are already in the session cache; + * re-injecting them wastes tokens and may be rejected by the CLI. + */ + it("passes --append-system-prompt-file on a fresh session when instructionsFile is set", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-fresh-")); + const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + try { + await execute({ + runId: "run-fresh", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async () => {}, + }); + const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")); + expect(captured.argv).toContain("--append-system-prompt-file"); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("omits --append-system-prompt-file on a resumed session even when instructionsFile is set", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-")); + const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + try { + await execute({ + runId: "run-resume", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async () => {}, + }); + const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")); + expect(captured.argv).not.toContain("--append-system-prompt-file"); + expect(captured.argv).toContain("--resume"); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + /** + * Regression tests for commandNotes accuracy (Greptile P2). + * + * commandNotes should only claim instructions were injected when the flag + * was actually passed — i.e. on fresh sessions, not resumed ones. + */ + it("commandNotes reports injection on a fresh session with instructionsFile", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-fresh-")); + const { workspace, commandPath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + let capturedNotes: string[] = []; + try { + await execute({ + runId: "run-notes-fresh", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: {}, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; }, + }); + expect(capturedNotes.some((n) => n.includes("--append-system-prompt-file"))).toBe(true); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("commandNotes is empty on a resumed session even when instructionsFile is set", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-resume-")); + const { workspace, commandPath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + let capturedNotes: string[] = ["sentinel"]; + try { + await execute({ + runId: "run-notes-resume", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: {}, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; }, + }); + expect(capturedNotes).toHaveLength(0); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("rebuilds the combined instructions file when an unknown resumed session falls back to fresh", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-")); + const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, { + commandWriter: writeRetryThenSucceedClaudeCommand, + }); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + const metaEvents: Array<{ commandArgs: string[]; commandNotes: string[] }> = []; + try { + const result = await execute({ + runId: "run-resume-fallback", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + PAPERCLIP_TEST_STATE_PATH: statePath, + }, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async (meta) => { + metaEvents.push({ + commandArgs: ((meta.commandArgs as string[]) ?? []).slice(), + commandNotes: ((meta.commandNotes as string[]) ?? []).slice(), + }); + }, + }); + const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")) as Array<{ + argv: string[]; + appendedSystemPromptFilePath: string | null; + appendedSystemPromptFileContents: string | null; + }>; + expect(captured).toHaveLength(2); + expect(captured[0]?.argv).toContain("--resume"); + expect(captured[0]?.argv).not.toContain("--append-system-prompt-file"); + expect(captured[1]?.argv).not.toContain("--resume"); + expect(captured[1]?.argv).toContain("--append-system-prompt-file"); + expect(captured[1]?.appendedSystemPromptFilePath).toContain("agent-instructions.md"); + expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile); + expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions"); + expect(captured[1]?.appendedSystemPromptFileContents).toContain( + `The above agent instructions were loaded from ${instructionsFile}. ` + + `Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`, + ); + expect(metaEvents).toHaveLength(2); + expect(metaEvents[0]?.commandNotes).toHaveLength(0); + expect(metaEvents[1]?.commandNotes.some((note) => note.includes("--append-system-prompt-file"))).toBe(true); + expect(result.sessionId).toBe("claude-session-2"); + expect(result.clearSession).toBe(false); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-")); const workspace = path.join(root, "workspace"); @@ -96,4 +383,259 @@ describe("claude execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("reuses a stable Paperclip-managed Claude prompt bundle across equivalent runs", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-bundle-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "claude"); + const capturePath1 = path.join(root, "capture-1.json"); + const capturePath2 = path.join(root, "capture-2.json"); + const instructionsPath = path.join(root, "AGENTS.md"); + const paperclipHome = path.join(root, "paperclip-home"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8"); + await writeFakeClaudeCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + process.env.HOME = root; + process.env.PAPERCLIP_HOME = paperclipHome; + + try { + const first = await execute({ + runId: "run-1", + 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: commandPath, + cwd: workspace, + instructionsFilePath: instructionsPath, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath1, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(first.exitCode).toBe(0); + expect(first.errorMessage).toBeNull(); + expect(first.sessionParams).toMatchObject({ + sessionId: "claude-session-1", + cwd: workspace, + }); + expect(typeof first.sessionParams?.promptBundleKey).toBe("string"); + + const second = await execute({ + runId: "run-2", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: first.sessionParams ?? null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + instructionsFilePath: instructionsPath, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath2, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { + issueId: "issue-1", + taskId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-2", + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-874", + title: "chat-speed issues", + status: "in_progress", + priority: "medium", + }, + commentIds: ["comment-2"], + latestCommentId: "comment-2", + comments: [ + { + id: "comment-2", + issueId: "issue-1", + body: "Second comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:10.000Z", + author: { type: "user", id: "user-1" }, + }, + ], + commentWindow: { + requestedCount: 1, + includedCount: 1, + missingCount: 0, + }, + truncated: false, + fallbackFetchNeeded: false, + }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(second.exitCode).toBe(0); + expect(second.errorMessage).toBeNull(); + + const capture1 = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload; + const capture2 = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload; + const expectedRoot = path.join( + paperclipHome, + "instances", + "default", + "companies", + "company-1", + "claude-prompt-cache", + ); + + expect(capture1.addDir).toBeTruthy(); + expect(capture1.addDir).toBe(capture2.addDir); + expect(capture1.instructionsFilePath).toBeTruthy(); + expect(capture2.instructionsFilePath ?? null).toBeNull(); + expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true); + expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true); + expect(capture1.instructionsContents).toContain("You are managed instructions."); + expect(capture1.instructionsContents).toContain(`The above agent instructions were loaded from ${instructionsPath}.`); + expect(capture1.skillEntries).toContain("paperclip"); + expect(capture2.argv).toContain("--resume"); + expect(capture2.argv).toContain("claude-session-1"); + expect(capture2.prompt).toContain("## Paperclip Resume Delta"); + expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat."); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("starts a fresh Claude session when the stable prompt bundle changes", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-reset-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "claude"); + const capturePath1 = path.join(root, "capture-before.json"); + const capturePath2 = path.join(root, "capture-after.json"); + const instructionsPath = path.join(root, "AGENTS.md"); + const paperclipHome = path.join(root, "paperclip-home"); + const logs: string[] = []; + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(instructionsPath, "Version one instructions.\n", "utf8"); + await writeFakeClaudeCommand(commandPath); + + const previousHome = process.env.HOME; + const previousPaperclipHome = process.env.PAPERCLIP_HOME; + process.env.HOME = root; + process.env.PAPERCLIP_HOME = paperclipHome; + + try { + const first = await execute({ + runId: "run-before", + 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: commandPath, + cwd: workspace, + instructionsFilePath: instructionsPath, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath1, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + await fs.writeFile(instructionsPath, "Version two instructions.\n", "utf8"); + + const second = await execute({ + runId: "run-after", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Claude Coder", + adapterType: "claude_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: first.sessionParams ?? null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + instructionsFilePath: instructionsPath, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath2, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: {}, + authToken: "run-jwt-token", + onLog: async (_stream, chunk) => { + logs.push(chunk); + }, + }); + + expect(first.exitCode).toBe(0); + expect(second.exitCode).toBe(0); + expect(second.errorMessage).toBeNull(); + + const before = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload; + const after = JSON.parse(await fs.readFile(capturePath2, "utf8")) as CapturePayload; + + expect(before.instructionsFilePath).not.toBe(after.instructionsFilePath); + expect(after.argv).not.toContain("--resume"); + expect(after.prompt).toContain("Follow the paperclip heartbeat."); + expect(logs.join("")).toContain("will not be resumed with"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; + else process.env.PAPERCLIP_HOME = previousPaperclipHome; + await fs.rm(root, { recursive: true, force: true }); + } + }, 15_000); }); diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index f364256515a..f71358c17b2 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; const mockAccessService = vi.hoisted(() => ({ isInstanceAdmin: vi.fn(), @@ -25,16 +27,14 @@ const mockBoardAuthService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); -function registerServiceMocks() { - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - boardAuthService: () => mockBoardAuthService, - logActivity: mockLogActivity, - notifyHireApproved: vi.fn(), - deduplicateAgentName: vi.fn((name: string) => name), - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + boardAuthService: () => mockBoardAuthService, + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), + deduplicateAgentName: vi.fn((name: string) => name), +})); function createApp(actor: any) { const app = express(); @@ -43,28 +43,22 @@ function createApp(actor: any) { req.actor = actor; next(); }); - return import("../routes/access.js").then(({ accessRoutes }) => - import("../middleware/index.js").then(({ errorHandler }) => { - app.use( - "/api", - accessRoutes({} as any, { - deploymentMode: "authenticated", - deploymentExposure: "private", - bindHost: "127.0.0.1", - allowedHostnames: [], - }), - ); - app.use(errorHandler); - return app; - }) + app.use( + "/api", + accessRoutes({} as any, { + deploymentMode: "authenticated", + deploymentExposure: "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), ); + app.use(errorHandler); + return app; } describe("cli auth routes", () => { beforeEach(() => { - vi.resetModules(); - registerServiceMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); }); it("creates a CLI auth challenge with approval metadata", async () => { @@ -77,7 +71,7 @@ describe("cli auth routes", () => { pendingBoardToken: "pcp_board_token", }); - const app = await createApp({ type: "none", source: "none" }); + const app = createApp({ type: "none", source: "none" }); const res = await request(app) .post("/api/cli-auth/challenges") .send({ @@ -113,7 +107,7 @@ describe("cli auth routes", () => { approvedByUser: null, }); - const app = await createApp({ type: "none", source: "none" }); + const app = createApp({ type: "none", source: "none" }); const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret"); expect(res.status).toBe(200); @@ -139,7 +133,7 @@ describe("cli auth routes", () => { }); mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]); - const app = await createApp({ + const app = createApp({ type: "board", userId: "user-1", source: "session", @@ -179,7 +173,7 @@ describe("cli auth routes", () => { }); mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]); - const app = await createApp({ + const app = createApp({ type: "board", userId: "admin-1", source: "session", @@ -206,7 +200,7 @@ describe("cli auth routes", () => { }); mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]); - const app = await createApp({ + const app = createApp({ type: "board", userId: "admin-2", keyId: "board-key-3", diff --git a/server/src/__tests__/company-branding-route.test.ts b/server/src/__tests__/company-branding-route.test.ts index bc3fa439978..a86e8c17b42 100644 --- a/server/src/__tests__/company-branding-route.test.ts +++ b/server/src/__tests__/company-branding-route.test.ts @@ -39,17 +39,15 @@ const mockFeedbackService = vi.hoisted(() => ({ saveIssueVote: vi.fn(), })); -function registerServiceMocks() { - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - budgetService: () => mockBudgetService, - companyPortabilityService: () => mockCompanyPortabilityService, - companyService: () => mockCompanyService, - feedbackService: () => mockFeedbackService, - logActivity: mockLogActivity, - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + budgetService: () => mockBudgetService, + companyPortabilityService: () => mockCompanyPortabilityService, + companyService: () => mockCompanyService, + feedbackService: () => mockFeedbackService, + logActivity: mockLogActivity, +})); function createCompany() { const now = new Date("2026-03-19T02:00:00.000Z"); @@ -90,7 +88,6 @@ async function createApp(actor: Record) { describe("PATCH /api/companies/:companyId/branding", () => { beforeEach(() => { vi.resetModules(); - registerServiceMocks(); vi.resetAllMocks(); }); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 8649f631f63..075f2d9bc60 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -175,4 +175,50 @@ describe("company portability routes", () => { expect(res.status).toBe(403); expect(res.body.error).toContain("Board access required"); }); + + it("requires instance admin for new-company import preview", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); + }); + + it("requires instance admin for new-company import apply", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 6dbad6593b8..890d7f1130b 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -20,23 +20,21 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackSkillImported = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -function registerRouteMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackSkillImported: mockTrackSkillImported, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackSkillImported: mockTrackSkillImported, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - companySkillService: () => mockCompanySkillService, - logActivity: mockLogActivity, - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + companySkillService: () => mockCompanySkillService, + logActivity: mockLogActivity, +})); async function createApp(actor: Record) { const [{ companySkillRoutes }, { errorHandler }] = await Promise.all([ @@ -57,8 +55,7 @@ async function createApp(actor: Record) { describe("company skill mutation permissions", () => { beforeEach(() => { vi.resetModules(); - registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockCompanySkillService.importFromSource.mockResolvedValue({ imported: [], diff --git a/server/src/__tests__/costs-service.test.ts b/server/src/__tests__/costs-service.test.ts index d6183067b01..9babd8b12cc 100644 --- a/server/src/__tests__/costs-service.test.ts +++ b/server/src/__tests__/costs-service.test.ts @@ -71,21 +71,19 @@ const mockBudgetService = vi.hoisted(() => ({ resolveIncident: vi.fn(), })); -function registerRouteMocks() { - vi.doMock("../services/index.js", () => ({ - budgetService: () => mockBudgetService, - costService: () => mockCostService, - financeService: () => mockFinanceService, - companyService: () => mockCompanyService, - agentService: () => mockAgentService, - heartbeatService: () => mockHeartbeatService, - logActivity: mockLogActivity, - })); +vi.mock("../services/index.js", () => ({ + budgetService: () => mockBudgetService, + costService: () => mockCostService, + financeService: () => mockFinanceService, + companyService: () => mockCompanyService, + agentService: () => mockAgentService, + heartbeatService: () => mockHeartbeatService, + logActivity: mockLogActivity, +})); - vi.doMock("../services/quota-windows.js", () => ({ - fetchAllQuotaWindows: mockFetchAllQuotaWindows, - })); -} +vi.mock("../services/quota-windows.js", () => ({ + fetchAllQuotaWindows: mockFetchAllQuotaWindows, +})); async function createApp() { const [{ costRoutes }, { errorHandler }] = await Promise.all([ @@ -119,10 +117,14 @@ async function createAppWithActor(actor: any) { return app; } +async function loadCostParsers() { + const { parseCostDateRange, parseCostLimit } = await import("../routes/costs.js"); + return { parseCostDateRange, parseCostLimit }; +} + beforeEach(() => { vi.resetModules(); - registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockCompanyService.update.mockResolvedValue({ id: "company-1", name: "Paperclip", @@ -140,30 +142,25 @@ beforeEach(() => { }); describe("cost routes", () => { - it("accepts valid ISO date strings and passes them to cost summary routes", async () => { - const app = await createApp(); - const res = await request(app) - .get("/api/companies/company-1/costs/summary") - .query({ from: "2026-01-01T00:00:00.000Z", to: "2026-01-31T23:59:59.999Z" }); - expect(res.status).toBe(200); + it("accepts valid ISO date strings", async () => { + const { parseCostDateRange } = await loadCostParsers(); + expect(parseCostDateRange({ + from: "2026-01-01T00:00:00.000Z", + to: "2026-01-31T23:59:59.999Z", + })).toEqual({ + from: new Date("2026-01-01T00:00:00.000Z"), + to: new Date("2026-01-31T23:59:59.999Z"), + }); }); it("returns 400 for an invalid 'from' date string", async () => { - const app = await createApp(); - const res = await request(app) - .get("/api/companies/company-1/costs/summary") - .query({ from: "not-a-date" }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid 'from' date/i); + const { parseCostDateRange } = await loadCostParsers(); + expect(() => parseCostDateRange({ from: "not-a-date" })).toThrow(/invalid 'from' date/i); }); it("returns 400 for an invalid 'to' date string", async () => { - const app = await createApp(); - const res = await request(app) - .get("/api/companies/company-1/costs/summary") - .query({ to: "banana" }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid 'to' date/i); + const { parseCostDateRange } = await loadCostParsers(); + expect(() => parseCostDateRange({ to: "banana" })).toThrow(/invalid 'to' date/i); }); it("returns finance summary rows for valid requests", async () => { @@ -176,21 +173,13 @@ describe("cost routes", () => { }); it("returns 400 for invalid finance event list limits", async () => { - const app = await createApp(); - const res = await request(app) - .get("/api/companies/company-1/costs/finance-events") - .query({ limit: "0" }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/invalid 'limit'/i); + const { parseCostLimit } = await loadCostParsers(); + expect(() => parseCostLimit({ limit: "0" })).toThrow(/invalid 'limit'/i); }); it("accepts valid finance event list limits", async () => { - const app = await createApp(); - const res = await request(app) - .get("/api/companies/company-1/costs/finance-events") - .query({ limit: "25" }); - expect(res.status).toBe(200); - expect(mockFinanceService.list).toHaveBeenCalledWith("company-1", undefined, 25); + const { parseCostLimit } = await loadCostParsers(); + expect(parseCostLimit({ limit: "25" })).toBe(25); }); it("rejects company budget updates for board users outside the company", async () => { diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts new file mode 100644 index 00000000000..461f2aabbfa --- /dev/null +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + bootstrapDevRunnerWorktreeEnv, + isLinkedGitWorktreeCheckout, + resolveWorktreeEnvFilePath, +} from "../dev-runner-worktree.ts"; + +const tempRoots = new Set(); + +afterEach(() => { + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +function createTempRoot(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.add(root); + return root; +} + +describe("dev-runner worktree env bootstrap", () => { + it("detects linked git worktrees from .git files", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-"); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + + expect(isLinkedGitWorktreeCheckout(root)).toBe(true); + }); + + it("loads repo-local Paperclip env for initialized worktrees without overriding explicit env", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-env-"); + fs.mkdirSync(path.join(root, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + fs.writeFileSync( + resolveWorktreeEnvFilePath(root), + [ + "PAPERCLIP_HOME=/tmp/paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=feature-worktree", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=feature-worktree", + "PAPERCLIP_OPTIONAL= # comment-only value", + "", + ].join("\n"), + "utf8", + ); + + const env: NodeJS.ProcessEnv = { + PAPERCLIP_INSTANCE_ID: "already-set", + }; + const result = bootstrapDevRunnerWorktreeEnv(root, env); + + expect(result).toEqual({ + envPath: resolveWorktreeEnvFilePath(root), + missingEnv: false, + }); + expect(env.PAPERCLIP_HOME).toBe("/tmp/paperclip-worktrees"); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("already-set"); + expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_OPTIONAL).toBe(""); + }); + + it("reports uninitialized linked worktrees so dev runner can fail fast", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-missing-"); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + + expect(bootstrapDevRunnerWorktreeEnv(root, {})).toEqual({ + envPath: resolveWorktreeEnvFilePath(root), + missingEnv: true, + }); + }); +}); diff --git a/server/src/__tests__/heartbeat-list.test.ts b/server/src/__tests__/heartbeat-list.test.ts new file mode 100644 index 00000000000..1c04009d3f6 --- /dev/null +++ b/server/src/__tests__/heartbeat-list.test.ts @@ -0,0 +1,91 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { agents, companies, createDb, heartbeatRuns } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { heartbeatService } from "../services/heartbeat.ts"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat list tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("heartbeat list", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-list-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("returns runs even when the linked db schema lacks processGroupId", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const runId = 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: "CodexCoder", + role: "engineer", + status: "running", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "assignment", + status: "running", + contextSnapshot: { issueId: randomUUID() }, + }); + + const originalDescriptor = Object.getOwnPropertyDescriptor(heartbeatRuns, "processGroupId"); + Object.defineProperty(heartbeatRuns, "processGroupId", { + value: undefined, + configurable: true, + writable: true, + }); + + try { + const runs = await heartbeatService(db).list(companyId, agentId, 5); + expect(runs).toHaveLength(1); + expect(runs[0]?.id).toBe(runId); + expect(runs[0]?.processGroupId ?? null).toBeNull(); + } finally { + if (originalDescriptor) { + Object.defineProperty(heartbeatRuns, "processGroupId", originalDescriptor); + } else { + delete (heartbeatRuns as Record).processGroupId; + } + } + }); +}); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 31a9cc59bdf..67569f7c9b2 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -49,10 +49,70 @@ function spawnAliveProcess() { }); } +function isPidAlive(pid: number | null | undefined) { + if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitForPidExit(pid: number, timeoutMs = 2_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isPidAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return !isPidAlive(pid); +} + +async function spawnOrphanedProcessGroup() { + const leader = spawn( + process.execPath, + [ + "-e", + [ + "const { spawn } = require('node:child_process');", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: 'ignore' });", + "process.stdout.write(String(child.pid));", + "setTimeout(() => process.exit(0), 25);", + ].join(" "), + ], + { + detached: true, + stdio: ["ignore", "pipe", "ignore"], + }, + ); + + let stdout = ""; + leader.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + + await new Promise((resolve, reject) => { + leader.once("error", reject); + leader.once("exit", () => resolve()); + }); + + const descendantPid = Number.parseInt(stdout.trim(), 10); + if (!Number.isInteger(descendantPid) || descendantPid <= 0) { + throw new Error(`Failed to capture orphaned descendant pid from detached process group: ${stdout}`); + } + + return { + processPid: leader.pid ?? null, + processGroupId: leader.pid ?? null, + descendantPid, + }; +} + describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; const childProcesses = new Set(); + const cleanupPids = new Set(); beforeAll(async () => { tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-"); @@ -66,6 +126,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { child.kill("SIGKILL"); } childProcesses.clear(); + for (const pid of cleanupPids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Ignore already-dead cleanup targets. + } + } + cleanupPids.clear(); await db.delete(issues); await db.delete(heartbeatRunEvents); await db.delete(heartbeatRuns); @@ -79,6 +147,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { child.kill("SIGKILL"); } childProcesses.clear(); + for (const pid of cleanupPids) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // Ignore already-dead cleanup targets. + } + } + cleanupPids.clear(); runningProcesses.clear(); await tempDb?.cleanup(); }); @@ -88,6 +164,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { agentStatus?: "paused" | "idle" | "running"; runStatus?: "running" | "queued" | "failed"; processPid?: number | null; + processGroupId?: number | null; processLossRetryCount?: number; includeIssue?: boolean; runErrorCode?: string | null; @@ -143,6 +220,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { wakeupRequestId, contextSnapshot: input?.includeIssue === false ? {} : { issueId }, processPid: input?.processPid ?? null, + processGroupId: input?.processGroupId ?? null, processLossRetryCount: input?.processLossRetryCount ?? 0, errorCode: input?.runErrorCode ?? null, error: input?.runError ?? null, @@ -228,6 +306,45 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(issue?.checkoutRunId).toBe(runId); }); + it.skipIf(process.platform === "win32")("reaps orphaned descendant process groups when the parent pid is already gone", async () => { + const orphan = await spawnOrphanedProcessGroup(); + cleanupPids.add(orphan.descendantPid); + expect(isPidAlive(orphan.descendantPid)).toBe(true); + + const { agentId, runId, issueId } = await seedRunFixture({ + processPid: orphan.processPid, + processGroupId: orphan.processGroupId, + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reapOrphanedRuns(); + expect(result.reaped).toBe(1); + expect(result.runIds).toEqual([runId]); + + expect(await waitForPidExit(orphan.descendantPid, 2_000)).toBe(true); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const failedRun = runs.find((row) => row.id === runId); + expect(failedRun?.status).toBe("failed"); + expect(failedRun?.errorCode).toBe("process_lost"); + expect(failedRun?.error).toContain("descendant process group"); + + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.status).toBe("queued"); + + const issue = await db + .select() + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue?.executionRunId).toBe(retryRun?.id ?? null); + }); + it("does not queue a second retry after the first process-loss retry was already used", async () => { const { agentId, runId, issueId } = await seedRunFixture({ processPid: 999_999_999, diff --git a/server/src/__tests__/heartbeat-run-summary.test.ts b/server/src/__tests__/heartbeat-run-summary.test.ts index 79efdabe703..7355f0d4d86 100644 --- a/server/src/__tests__/heartbeat-run-summary.test.ts +++ b/server/src/__tests__/heartbeat-run-summary.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { summarizeHeartbeatRunResultJson, buildHeartbeatRunIssueComment, + mergeHeartbeatRunResultJson, } from "../services/heartbeat-run-summary.js"; describe("summarizeHeartbeatRunResultJson", () => { @@ -55,3 +56,35 @@ describe("buildHeartbeatRunIssueComment", () => { expect(buildHeartbeatRunIssueComment({ costUsd: 1.2 })).toBeNull(); }); }); + +describe("mergeHeartbeatRunResultJson", () => { + it("adds adapter summaries into stored result json for comment posting", () => { + const merged = mergeHeartbeatRunResultJson( + { stdout: "raw stdout", stderr: "" }, + "## Summary\n\n1. first thing\n2. second thing", + ); + + expect(merged).toEqual({ + stdout: "raw stdout", + stderr: "", + summary: "## Summary\n\n1. first thing\n2. second thing", + }); + expect(buildHeartbeatRunIssueComment(merged)).toBe("## Summary\n\n1. first thing\n2. second thing"); + }); + + it("creates a result payload when only a summary exists", () => { + expect(mergeHeartbeatRunResultJson(null, "done")).toEqual({ summary: "done" }); + }); + + it("does not overwrite an explicit summary already returned by the adapter", () => { + expect( + mergeHeartbeatRunResultJson( + { summary: "adapter result", stdout: "raw stdout" }, + "fallback summary", + ), + ).toEqual({ + summary: "adapter result", + stdout: "raw stdout", + }); + }); +}); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index b09d8393dbe..12f8ff7af3d 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -11,12 +11,10 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ })); const mockLogActivity = vi.hoisted(() => vi.fn()); -function registerRouteMocks() { - vi.doMock("../services/index.js", () => ({ - instanceSettingsService: () => mockInstanceSettingsService, - logActivity: mockLogActivity, - })); -} +vi.mock("../services/index.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + logActivity: mockLogActivity, +})); async function createApp(actor: any) { const [{ instanceSettingsRoutes }, { errorHandler }] = await Promise.all([ @@ -37,8 +35,7 @@ async function createApp(actor: any) { describe("instance settings routes", () => { beforeEach(() => { vi.resetModules(); - registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockInstanceSettingsService.getGeneral.mockResolvedValue({ censorUsernameInLogs: false, keyboardShortcuts: false, diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index d4e99a8e54f..9876477214d 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -1,8 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { errorHandler } from "../middleware/index.js"; import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; const mockIssueService = vi.hoisted(() => ({ @@ -170,7 +170,7 @@ describe("issue activity event routes", () => { }), }), ); - }); + }, 15_000); it("logs explicit reviewer and approver activity when execution policy participants change", async () => { const existingPolicy = normalizeIssueExecutionPolicy({ diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index ed2a68859e5..78b75d3a974 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -38,48 +38,49 @@ const mockTx = vi.hoisted(() => ({ const mockDb = vi.hoisted(() => ({ transaction: vi.fn(async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx)), })); +const mockFeedbackService = vi.hoisted(() => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), +})); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), +})); +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); -function registerServiceMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({ - listIssueVotesForUser: vi.fn(async () => []), - saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), - }), - goalService: () => ({}), - heartbeatService: () => mockHeartbeatService, - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: mockLogActivity, - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), +})); function createApp() { const app = express(); @@ -134,7 +135,7 @@ function makeIssue(status: "todo" | "done") { describe("issue comment reopen routes", () => { beforeEach(() => { vi.resetModules(); - registerServiceMocks(); + vi.resetAllMocks(); mockIssueService.getById.mockReset(); mockIssueService.assertCheckoutOwner.mockReset(); mockIssueService.update.mockReset(); @@ -151,6 +152,11 @@ describe("issue comment reopen routes", () => { mockHeartbeatService.cancelRun.mockReset(); mockAgentService.getById.mockReset(); mockLogActivity.mockReset(); + mockFeedbackService.listIssueVotesForUser.mockReset(); + mockFeedbackService.saveIssueVote.mockReset(); + mockInstanceSettingsService.get.mockReset(); + mockInstanceSettingsService.listCompanyIds.mockReset(); + mockRoutineService.syncRunStatusForIssue.mockReset(); mockTxInsertValues.mockReset(); mockTxInsert.mockReset(); mockDb.transaction.mockReset(); @@ -163,6 +169,21 @@ describe("issue comment reopen routes", () => { mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); mockHeartbeatService.cancelRun.mockResolvedValue(null); mockLogActivity.mockResolvedValue(undefined); + mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]); + mockFeedbackService.saveIssueVote.mockResolvedValue({ + vote: null, + consentEnabledNow: false, + sharingEnabled: false, + }); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); mockIssueService.addComment.mockResolvedValue({ id: "comment-1", issueId: "11111111-1111-4111-8111-111111111111", diff --git a/server/src/__tests__/issue-document-restore-routes.test.ts b/server/src/__tests__/issue-document-restore-routes.test.ts index d576d3ac4bd..386da1e2ae1 100644 --- a/server/src/__tests__/issue-document-restore-routes.test.ts +++ b/server/src/__tests__/issue-document-restore-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { issueRoutes } from "../routes/issues.js"; +import { errorHandler } from "../middleware/index.js"; const issueId = "11111111-1111-4111-8111-111111111111"; const companyId = "22222222-2222-4222-8222-222222222222"; @@ -50,11 +52,7 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -async function createApp() { - const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), - ]); +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -74,7 +72,6 @@ async function createApp() { describe("issue document revision routes", () => { beforeEach(() => { - vi.resetModules(); vi.resetAllMocks(); mockIssueService.getById.mockResolvedValue({ id: issueId, @@ -125,10 +122,9 @@ describe("issue document revision routes", () => { }); it("returns revision snapshots including title and format", async () => { - const res = await request(await createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); + const res = await request(createApp()).get(`/api/issues/${issueId}/documents/plan/revisions`); expect(res.status).toBe(200); - expect(mockDocumentsService.listIssueDocumentRevisions).toHaveBeenCalledWith(issueId, "plan"); expect(res.body).toEqual([ expect.objectContaining({ revisionNumber: 2, @@ -140,7 +136,7 @@ describe("issue document revision routes", () => { }); it("restores a revision through the append-only route and logs the action", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/plan/revisions/revision-1/restore`) .send({}); @@ -172,7 +168,7 @@ describe("issue document revision routes", () => { }); it("rejects invalid document keys before attempting restore", async () => { - const res = await request(await createApp()) + const res = await request(createApp()) .post(`/api/issues/${issueId}/documents/INVALID KEY/revisions/revision-1/restore`) .send({}); diff --git a/server/src/__tests__/issue-execution-policy-routes.test.ts b/server/src/__tests__/issue-execution-policy-routes.test.ts index 190cb077af9..4f8d9fc626c 100644 --- a/server/src/__tests__/issue-execution-policy-routes.test.ts +++ b/server/src/__tests__/issue-execution-policy-routes.test.ts @@ -60,19 +60,17 @@ vi.mock("../services/index.js", () => ({ workProductService: () => ({}), })); -function createApp( - actor: Record = { - type: "board", - userId: "local-board", - companyIds: ["company-1"], - source: "local_implicit", - isInstanceAdmin: false, - }, -) { +function createApp() { const app = express(); app.use(express.json()); app.use((req, _res, next) => { - (req as any).actor = actor; + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; next(); }); app.use("/api", issueRoutes({} as any, {} as any)); @@ -139,63 +137,4 @@ describe("issue execution policy routes", () => { expect(updatePatch.executionState).toBeUndefined(); expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); }); - - it("rejects agent stage advances from non-participants", async () => { - const reviewerAgentId = "33333333-3333-4333-8333-333333333333"; - const approverAgentId = "44444444-4444-4444-8444-444444444444"; - const executorAgentId = "22222222-2222-4222-8222-222222222222"; - const policy = normalizeIssueExecutionPolicy({ - stages: [ - { - id: "11111111-1111-4111-8111-111111111111", - type: "review", - participants: [{ type: "agent", agentId: reviewerAgentId }], - }, - { - id: "55555555-5555-4555-8555-555555555555", - type: "approval", - participants: [{ type: "agent", agentId: approverAgentId }], - }, - ], - })!; - const issue = { - id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - companyId: "company-1", - status: "in_review", - assigneeAgentId: reviewerAgentId, - assigneeUserId: null, - createdByUserId: "local-board", - identifier: "PAP-1000", - title: "Execution policy guard", - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: "11111111-1111-4111-8111-111111111111", - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: reviewerAgentId }, - returnAssignee: { type: "agent", agentId: executorAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, - }, - }; - mockIssueService.getById.mockResolvedValue(issue); - - const res = await request( - createApp({ - type: "agent", - agentId: approverAgentId, - companyId: "company-1", - source: "api_key", - runId: "run-1", - }), - ) - .patch("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") - .send({ status: "done", comment: "Skipping review." }); - - expect(res.status).toBe(403); - expect(res.body.error).toContain("active review participant"); - expect(mockIssueService.update).not.toHaveBeenCalled(); - }); }); diff --git a/server/src/__tests__/issue-execution-policy.test.ts b/server/src/__tests__/issue-execution-policy.test.ts index 7271b49935c..3d8a649b7d4 100644 --- a/server/src/__tests__/issue-execution-policy.test.ts +++ b/server/src/__tests__/issue-execution-policy.test.ts @@ -413,45 +413,33 @@ describe("issue execution policy transitions", () => { const policy = twoStagePolicy(); const reviewStageId = policy.stages[0].id; - it("non-participant stage updates are coerced back to the active stage", () => { - const result = applyIssueExecutionPolicyTransition({ - issue: { - status: "in_review", - assigneeAgentId: qaAgentId, - assigneeUserId: null, - executionPolicy: policy, - executionState: { - status: "pending", - currentStageId: reviewStageId, - currentStageIndex: 0, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: qaAgentId }, - returnAssignee: { type: "agent", agentId: coderAgentId }, - completedStageIds: [], - lastDecisionId: null, - lastDecisionOutcome: null, + it("non-participant cannot advance the active stage", () => { + expect(() => + applyIssueExecutionPolicyTransition({ + issue: { + status: "in_review", + assigneeAgentId: qaAgentId, + assigneeUserId: null, + executionPolicy: policy, + executionState: { + status: "pending", + currentStageId: reviewStageId, + currentStageIndex: 0, + currentStageType: "review", + currentParticipant: { type: "agent", agentId: qaAgentId }, + returnAssignee: { type: "agent", agentId: coderAgentId }, + completedStageIds: [], + lastDecisionId: null, + lastDecisionOutcome: null, + }, }, - }, - policy, - requestedStatus: "done", - requestedAssigneePatch: { assigneeUserId: boardUserId }, - actor: { agentId: coderAgentId }, - commentBody: "Trying to bypass review", - }); - - expect(result.patch).toMatchObject({ - status: "in_review", - assigneeAgentId: qaAgentId, - assigneeUserId: null, - executionState: { - status: "pending", - currentStageId: reviewStageId, - currentStageType: "review", - currentParticipant: { type: "agent", agentId: qaAgentId }, - returnAssignee: { type: "agent", agentId: coderAgentId }, - }, - }); - expect(result.decision).toBeUndefined(); + policy, + requestedStatus: "done", + requestedAssigneePatch: { assigneeUserId: boardUserId }, + actor: { agentId: coderAgentId }, + commentBody: "Trying to bypass review", + }), + ).toThrow("Only the active reviewer or approver can advance"); }); it("non-participant can still post non-advancing updates", () => { diff --git a/server/src/__tests__/issue-feedback-routes.test.ts b/server/src/__tests__/issue-feedback-routes.test.ts index 2374ec1e777..e47c00ee2fa 100644 --- a/server/src/__tests__/issue-feedback-routes.test.ts +++ b/server/src/__tests__/issue-feedback-routes.test.ts @@ -21,56 +21,60 @@ const mockIssueService = vi.hoisted(() => ({ const mockFeedbackExportService = vi.hoisted(() => ({ flushPendingFeedbackTraces: vi.fn(async () => ({ attempted: 1, sent: 1, failed: 0 })), })); +const mockAccessService = vi.hoisted(() => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), +})); +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); +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 mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), +})); +const mockRoutineService = vi.hoisted(() => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), +})); +const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); -function registerServiceMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: vi.fn(), - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: vi.fn(), + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: vi.fn(() => ({ track: vi.fn() })), +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => ({ - getById: vi.fn(), - }), - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => mockFeedbackService, - goalService: () => ({}), - heartbeatService: () => ({ - 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), - }), - instanceSettingsService: () => ({ - get: vi.fn(async () => ({ - id: "instance-settings-1", - general: { - censorUsernameInLogs: false, - feedbackDataSharingPreference: "prompt", - }, - })), - listCompanyIds: vi.fn(async () => ["company-1"]), - }), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => mockFeedbackService, + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => mockInstanceSettingsService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + projectService: () => ({}), + routineService: () => mockRoutineService, + workProductService: () => ({}), +})); async function createApp(actor: Record) { const [{ issueRoutes }, { errorHandler }] = await Promise.all([ @@ -91,13 +95,27 @@ async function createApp(actor: Record) { describe("issue feedback trace routes", () => { beforeEach(() => { vi.resetModules(); - registerServiceMocks(); vi.resetAllMocks(); mockFeedbackExportService.flushPendingFeedbackTraces.mockResolvedValue({ attempted: 1, sent: 1, failed: 0, }); + mockHeartbeatService.wakeup.mockResolvedValue(undefined); + mockHeartbeatService.reportRunActivity.mockResolvedValue(undefined); + mockHeartbeatService.getRun.mockResolvedValue(null); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(null); + mockHeartbeatService.cancelRun.mockResolvedValue(null); + mockInstanceSettingsService.get.mockResolvedValue({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + }); + mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]); + mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined); + mockLogActivity.mockResolvedValue(undefined); }); it("flushes a newly shared feedback trace immediately after saving the vote", async () => { diff --git a/server/src/__tests__/issue-telemetry-routes.test.ts b/server/src/__tests__/issue-telemetry-routes.test.ts index 1b0196d60be..b6aa00206eb 100644 --- a/server/src/__tests__/issue-telemetry-routes.test.ts +++ b/server/src/__tests__/issue-telemetry-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), @@ -16,41 +18,39 @@ const mockAgentService = vi.hoisted(() => ({ const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -function registerRouteMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackAgentTaskCompleted: mockTrackAgentTaskCompleted, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackAgentTaskCompleted: mockTrackAgentTaskCompleted, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => ({ - canUser: vi.fn(), - hasPermission: vi.fn(), - }), - agentService: () => mockAgentService, - documentService: () => ({}), - executionWorkspaceService: () => ({}), - feedbackService: () => ({}), - goalService: () => ({}), - heartbeatService: () => ({ - wakeup: vi.fn(async () => undefined), - reportRunActivity: vi.fn(async () => undefined), - }), - instanceSettingsService: () => ({}), - issueApprovalService: () => ({}), - issueService: () => mockIssueService, - logActivity: vi.fn(async () => undefined), - projectService: () => ({}), - routineService: () => ({ - syncRunStatusForIssue: vi.fn(async () => undefined), - }), - workProductService: () => ({}), - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(), + hasPermission: vi.fn(), + }), + agentService: () => mockAgentService, + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({}), + goalService: () => ({}), + heartbeatService: () => ({ + wakeup: vi.fn(async () => undefined), + reportRunActivity: vi.fn(async () => undefined), + }), + instanceSettingsService: () => ({}), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); function makeIssue(status: "todo" | "done") { return { @@ -65,11 +65,7 @@ function makeIssue(status: "todo" | "done") { }; } -async function createApp(actor: Record) { - const [{ issueRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/issues.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -83,8 +79,6 @@ async function createApp(actor: Record) { describe("issue telemetry routes", () => { beforeEach(() => { - vi.resetModules(); - registerRouteMocks(); vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockIssueService.getById.mockResolvedValue(makeIssue("todo")); @@ -104,7 +98,7 @@ describe("issue telemetry routes", () => { adapterType: "codex_local", }); - const app = await createApp({ + const app = createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", @@ -124,7 +118,7 @@ describe("issue telemetry routes", () => { }, 10_000); it("does not emit agent task-completed telemetry for board-driven completions", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "local-board", companyIds: ["company-1"], diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts new file mode 100644 index 00000000000..c6bf91779df --- /dev/null +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -0,0 +1,202 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { issueRoutes } from "../routes/issues.js"; + +const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; + +const mockIssueService = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + addComment: vi.fn(), + findMentionedAgents: vi.fn(), + getRelationSummaries: vi.fn(), + listWakeableBlockedDependents: vi.fn(), + getWakeableParentAfterChildCompletion: vi.fn(), +})); + +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), +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({ + canUser: vi.fn(async () => true), + hasPermission: vi.fn(async () => true), + }), + agentService: () => ({ + getById: vi.fn(async () => null), + }), + documentService: () => ({}), + executionWorkspaceService: () => ({}), + feedbackService: () => ({ + listIssueVotesForUser: vi.fn(async () => []), + saveIssueVote: vi.fn(async () => ({ vote: null, consentEnabledNow: false, sharingEnabled: false })), + }), + goalService: () => ({}), + heartbeatService: () => mockHeartbeatService, + instanceSettingsService: () => ({ + get: vi.fn(async () => ({ + id: "instance-settings-1", + general: { + censorUsernameInLogs: false, + feedbackDataSharingPreference: "prompt", + }, + })), + listCompanyIds: vi.fn(async () => ["company-1"]), + }), + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: vi.fn(async () => undefined), + projectService: () => ({}), + routineService: () => ({ + syncRunStatusForIssue: vi.fn(async () => undefined), + }), + workProductService: () => ({}), +})); + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = { + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", issueRoutes({} as any, {} as any)); + app.use(errorHandler); + return app; +} + +function makeIssue(overrides: Record = {}) { + return { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + companyId: "company-1", + status: "todo", + priority: "medium", + projectId: null, + goalId: null, + parentId: null, + assigneeAgentId: null, + assigneeUserId: "local-board", + createdByUserId: "local-board", + identifier: "PAP-999", + title: "Wake test", + executionPolicy: null, + executionState: null, + hiddenAt: null, + ...overrides, + }; +} + +describe("issue update comment wakeups", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] }); + mockIssueService.listWakeableBlockedDependents.mockResolvedValue([]); + mockIssueService.getWakeableParentAfterChildCompletion.mockResolvedValue(null); + }); + + it("includes the new comment in assignment wakes from issue updates", async () => { + const existing = makeIssue(); + const updated = makeIssue({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-1", + issueId: existing.id, + companyId: existing.companyId, + body: "write the whole thing", + }); + + const res = await request(createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + comment: "write the whole thing", + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "assignment", + reason: "issue_assigned", + payload: expect.objectContaining({ + issueId: existing.id, + commentId: "comment-1", + mutation: "update", + }), + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-1", + wakeCommentId: "comment-1", + source: "issue.update", + }), + }), + ); + }); + + it("wakes the assignee on comment-only issue updates", async () => { + const existing = makeIssue({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + status: "in_progress", + }); + const updated = { ...existing }; + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-2", + issueId: existing.id, + companyId: existing.companyId, + body: "please revise this", + }); + + const res = await request(createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + comment: "please revise this", + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_commented", + payload: expect.objectContaining({ + issueId: existing.id, + commentId: "comment-2", + mutation: "comment", + }), + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-2", + wakeCommentId: "comment-2", + wakeReason: "issue_commented", + source: "issue.comment", + }), + }), + ); + }); +}); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 89f75a706bb..3bd47343ac8 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -249,6 +249,135 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); }); + it("applies result limits to issue search", async () => { + const companyId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + const exactIdentifierId = randomUUID(); + const titleMatchId = randomUUID(); + const descriptionMatchId = randomUUID(); + + await db.insert(issues).values([ + { + id: exactIdentifierId, + companyId, + issueNumber: 42, + identifier: "PAP-42", + title: "Completely unrelated", + status: "todo", + priority: "medium", + }, + { + id: titleMatchId, + companyId, + title: "Search ranking issue", + status: "todo", + priority: "medium", + }, + { + id: descriptionMatchId, + companyId, + title: "Another item", + description: "Contains the search keyword", + status: "todo", + priority: "medium", + }, + ]); + + const result = await svc.list(companyId, { + q: "search", + limit: 2, + }); + + expect(result.map((issue) => issue.id)).toEqual([titleMatchId, descriptionMatchId]); + }); + + it("ranks comment matches ahead of description-only matches", async () => { + const companyId = randomUUID(); + const commentMatchId = randomUUID(); + const descriptionMatchId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values([ + { + id: commentMatchId, + companyId, + title: "Comment match", + status: "todo", + priority: "medium", + }, + { + id: descriptionMatchId, + companyId, + title: "Description match", + description: "Contains pull/3303 in the description", + status: "todo", + priority: "medium", + }, + ]); + + await db.insert(issueComments).values({ + companyId, + issueId: commentMatchId, + body: "Reference: https://github.com/paperclipai/paperclip/pull/3303", + }); + + const result = await svc.list(companyId, { + q: "pull/3303", + limit: 2, + includeRoutineExecutions: true, + }); + + expect(result.map((issue) => issue.id)).toEqual([commentMatchId, descriptionMatchId]); + }); + + it("accepts issue identifiers through getById", async () => { + const companyId = randomUUID(); + const issueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: "PAP", + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + issueNumber: 1064, + identifier: "PAP-1064", + title: "Feedback votes error", + status: "todo", + priority: "medium", + createdByUserId: "user-1", + }); + + const issue = await svc.getById("PAP-1064"); + + expect(issue).toEqual( + expect.objectContaining({ + id: issueId, + identifier: "PAP-1064", + }), + ); + }); + + it("returns null instead of throwing for malformed non-uuid issue refs", async () => { + await expect(svc.getById("not-a-uuid")).resolves.toBeNull(); + }); it("filters issues by execution workspace id", async () => { const companyId = randomUUID(); const projectId = randomUUID(); diff --git a/server/src/__tests__/llms-routes.test.ts b/server/src/__tests__/llms-routes.test.ts new file mode 100644 index 00000000000..0bc34bbcf75 --- /dev/null +++ b/server/src/__tests__/llms-routes.test.ts @@ -0,0 +1,56 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { llmRoutes } from "../routes/llms.js"; +import { errorHandler } from "../middleware/index.js"; + +const mockAgentService = vi.hoisted(() => ({ + getById: vi.fn(), +})); + +const mockListServerAdapters = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + agentService: () => mockAgentService, +})); + +vi.mock("../adapters/index.js", () => ({ + listServerAdapters: mockListServerAdapters, +})); + +function createApp(actor: Record) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api", llmRoutes({} as never)); + app.use(errorHandler); + return app; +} + +describe("llm routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListServerAdapters.mockReturnValue([ + { type: "codex_local", agentConfigurationDoc: "# codex_local agent configuration" }, + ]); + }); + + it("documents timer heartbeats as opt-in for new hires", async () => { + const app = createApp({ + type: "board", + userId: "board-user", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: true, + }); + + const res = await request(app).get("/api/llms/agent-configuration.txt"); + + expect(res.status).toBe(200); + expect(res.text).toContain("Timer heartbeats are opt-in for new hires."); + expect(res.text).toContain("Leave runtimeConfig.heartbeat.enabled false"); + }); +}); diff --git a/server/src/__tests__/openclaw-invite-prompt-route.test.ts b/server/src/__tests__/openclaw-invite-prompt-route.test.ts index 15e3e73f27e..8f75d4ca8b8 100644 --- a/server/src/__tests__/openclaw-invite-prompt-route.test.ts +++ b/server/src/__tests__/openclaw-invite-prompt-route.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { accessRoutes } from "../routes/access.js"; +import { errorHandler } from "../middleware/index.js"; const mockAccessService = vi.hoisted(() => ({ hasPermission: vi.fn(), @@ -33,16 +35,14 @@ const mockBoardAuthService = vi.hoisted(() => ({ const mockLogActivity = vi.hoisted(() => vi.fn()); -function registerServiceMocks() { - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - agentService: () => mockAgentService, - boardAuthService: () => mockBoardAuthService, - deduplicateAgentName: vi.fn(), - logActivity: mockLogActivity, - notifyHireApproved: vi.fn(), - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + agentService: () => mockAgentService, + boardAuthService: () => mockBoardAuthService, + deduplicateAgentName: vi.fn(), + logActivity: mockLogActivity, + notifyHireApproved: vi.fn(), +})); function createDbStub() { const createdInvite = { @@ -105,11 +105,7 @@ function createDbStub() { }; } -async function createApp(actor: Record, db: Record) { - const [{ accessRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/access.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record, db: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -131,9 +127,7 @@ async function createApp(actor: Record, db: Record { beforeEach(() => { - vi.resetModules(); - registerServiceMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockAccessService.canUser.mockResolvedValue(false); mockAgentService.getById.mockReset(); mockLogActivity.mockResolvedValue(undefined); @@ -146,7 +140,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { companyId: "company-1", role: "engineer", }); - const app = await createApp( + const app = createApp( { type: "agent", agentId: "agent-1", @@ -171,7 +165,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { companyId: "company-1", role: "ceo", }); - const app = await createApp( + const app = createApp( { type: "agent", agentId: "agent-1", @@ -199,7 +193,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("includes companyName in invite summary responses", async () => { const db = createDbStub(); - const app = await createApp( + const app = createApp( { type: "board", userId: "user-1", @@ -223,7 +217,7 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { it("allows board callers with invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(true); - const app = await createApp( + const app = createApp( { type: "board", userId: "user-1", @@ -246,12 +240,12 @@ describe("POST /companies/:companyId/openclaw/invite-prompt", () => { allowedJoinTypes: "agent", }), ); - }); + }, 15_000); it("rejects board callers without invite permission", async () => { const db = createDbStub(); mockAccessService.canUser.mockResolvedValue(false); - const app = await createApp( + const app = createApp( { type: "board", userId: "user-1", diff --git a/server/src/__tests__/private-hostname-guard.test.ts b/server/src/__tests__/private-hostname-guard.test.ts index 13cdebbcae5..c43648a167b 100644 --- a/server/src/__tests__/private-hostname-guard.test.ts +++ b/server/src/__tests__/private-hostname-guard.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import express from "express"; import request from "supertest"; -import { privateHostnameGuard } from "../middleware/private-hostname-guard.js"; const unknownHostname = "blocked-host.invalid"; -function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) { +async function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHost?: string }) { + const { privateHostnameGuard } = await import("../middleware/private-hostname-guard.js"); const app = express(); app.use( privateHostnameGuard({ @@ -24,33 +24,37 @@ function createApp(opts: { enabled: boolean; allowedHostnames?: string[]; bindHo } describe("privateHostnameGuard", () => { + beforeEach(() => { + vi.resetModules(); + }); + it("allows requests when disabled", async () => { - const app = createApp({ enabled: false }); + const app = await createApp({ enabled: false }); const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100"); expect(res.status).toBe(200); }); it("allows loopback hostnames", async () => { - const app = createApp({ enabled: true }); + const app = await createApp({ enabled: true }); const res = await request(app).get("/api/health").set("Host", "localhost:3100"); expect(res.status).toBe(200); }); it("allows explicitly configured hostnames", async () => { - const app = createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] }); + const app = await createApp({ enabled: true, allowedHostnames: ["dotta-macbook-pro"] }); const res = await request(app).get("/api/health").set("Host", "dotta-macbook-pro:3100"); expect(res.status).toBe(200); }); it("blocks unknown hostnames with remediation command", async () => { - const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] }); + const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] }); const res = await request(app).get("/api/health").set("Host", `${unknownHostname}:3100`); expect(res.status).toBe(403); expect(res.body?.error).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`); }); it("blocks unknown hostnames on page routes with plain-text remediation command", async () => { - const app = createApp({ enabled: true, allowedHostnames: ["some-other-host"] }); + const app = await createApp({ enabled: true, allowedHostnames: ["some-other-host"] }); const res = await request(app).get("/dashboard").set("Host", `${unknownHostname}:3100`); expect(res.status).toBe(403); expect(res.text).toContain(`please run pnpm paperclipai allowed-hostname ${unknownHostname}`); diff --git a/server/src/__tests__/project-routes-env.test.ts b/server/src/__tests__/project-routes-env.test.ts index 895cc02e5d4..25636f6c0c3 100644 --- a/server/src/__tests__/project-routes-env.test.ts +++ b/server/src/__tests__/project-routes-env.test.ts @@ -21,21 +21,23 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockLogActivity = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -vi.mock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, -})); +function registerModuleMocks() { + vi.doMock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, + })); -vi.mock("../services/index.js", () => ({ - logActivity: mockLogActivity, - projectService: () => mockProjectService, - secretService: () => mockSecretService, - workspaceOperationService: () => mockWorkspaceOperationService, -})); + vi.doMock("../services/index.js", () => ({ + logActivity: mockLogActivity, + projectService: () => mockProjectService, + secretService: () => mockSecretService, + workspaceOperationService: () => mockWorkspaceOperationService, + })); -vi.mock("../services/workspace-runtime.js", () => ({ - startRuntimeServicesForWorkspaceControl: vi.fn(), - stopRuntimeServicesForProjectWorkspace: vi.fn(), -})); + vi.doMock("../services/workspace-runtime.js", () => ({ + startRuntimeServicesForWorkspaceControl: vi.fn(), + stopRuntimeServicesForProjectWorkspace: vi.fn(), + })); +} async function createApp() { const { projectRoutes } = await import("../routes/projects.js"); @@ -97,6 +99,8 @@ function buildProject(overrides: Record = {}) { describe("project env routes", () => { beforeEach(() => { + vi.resetModules(); + registerModuleMocks(); vi.clearAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null }); @@ -160,10 +164,6 @@ describe("project env routes", () => { }); expect(res.status, JSON.stringify(res.body)).toBe(200); - expect(mockProjectService.update).toHaveBeenCalledWith( - "project-1", - expect.objectContaining({ env: normalizedEnv }), - ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 12a3d837202..deb3e1108b0 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -26,56 +26,53 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; -function registerServiceMocks() { - vi.doMock("../services/index.js", async () => { - const actual = await vi.importActual("../services/index.js"); +vi.mock("../services/index.js", async () => { + const actual = await vi.importActual("../services/index.js"); - return { - ...actual, - routineService: (db: any) => - actual.routineService(db, { - heartbeat: { - wakeup: async (agentId: string, wakeupOpts: any) => { - const issueId = - (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || - (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || - null; - if (!issueId) return null; + return { + ...actual, + routineService: (db: any) => + actual.routineService(db, { + heartbeat: { + wakeup: async (agentId: string, wakeupOpts: any) => { + const issueId = + (typeof wakeupOpts?.payload?.issueId === "string" && wakeupOpts.payload.issueId) || + (typeof wakeupOpts?.contextSnapshot?.issueId === "string" && wakeupOpts.contextSnapshot.issueId) || + null; + if (!issueId) return null; - const issue = await db - .select({ companyId: issues.companyId }) - .from(issues) - .where(eq(issues.id, issueId)) - .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); - if (!issue) return null; + const issue = await db + .select({ companyId: issues.companyId }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows: Array<{ companyId: string }>) => rows[0] ?? null); + if (!issue) return null; - const queuedRunId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: queuedRunId, - companyId: issue.companyId, - agentId, - invocationSource: wakeupOpts?.source ?? "assignment", - triggerDetail: wakeupOpts?.triggerDetail ?? null, - status: "queued", - contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, - }); - await db - .update(issues) - .set({ - executionRunId: queuedRunId, - executionLockedAt: new Date(), - }) - .where(eq(issues.id, issueId)); - return { id: queuedRunId }; - }, + const queuedRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: queuedRunId, + companyId: issue.companyId, + agentId, + invocationSource: wakeupOpts?.source ?? "assignment", + triggerDetail: wakeupOpts?.triggerDetail ?? null, + status: "queued", + contextSnapshot: { ...(wakeupOpts?.contextSnapshot ?? {}), issueId }, + }); + await db + .update(issues) + .set({ + executionRunId: queuedRunId, + executionLockedAt: new Date(), + }) + .where(eq(issues.id, issueId)); + return { id: queuedRunId }; }, - }), - }; - }); -} + }, + }), + }; +}); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -95,11 +92,6 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { db = createDb(tempDb.connectionString); }, 20_000); - beforeEach(() => { - vi.resetModules(); - registerServiceMocks(); - }); - afterEach(async () => { await db.delete(activityLog); await db.delete(routineRuns); @@ -123,8 +115,15 @@ describeEmbeddedPostgres("routine routes end-to-end", () => { await tempDb?.cleanup(); }); + beforeEach(() => { + vi.resetModules(); + }); + async function createApp(actor: Record) { - const { routineRoutes } = await import("../routes/routines.js"); + const [{ routineRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/routines.js"), + import("../middleware/index.js"), + ]); const app = express(); app.use(express.json()); app.use((req, _res, next) => { diff --git a/server/src/__tests__/routines-routes.test.ts b/server/src/__tests__/routines-routes.test.ts index ce1db54950f..d3a9edea15c 100644 --- a/server/src/__tests__/routines-routes.test.ts +++ b/server/src/__tests__/routines-routes.test.ts @@ -1,6 +1,8 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { errorHandler } from "../middleware/index.js"; +import { routineRoutes } from "../routes/routines.js"; const companyId = "22222222-2222-4222-8222-222222222222"; const agentId = "11111111-1111-4111-8111-111111111111"; @@ -83,28 +85,22 @@ const mockLogActivity = vi.hoisted(() => vi.fn()); const mockTrackRoutineCreated = vi.hoisted(() => vi.fn()); const mockGetTelemetryClient = vi.hoisted(() => vi.fn()); -function registerRouteMocks() { - vi.doMock("@paperclipai/shared/telemetry", () => ({ - trackRoutineCreated: mockTrackRoutineCreated, - trackErrorHandlerCrash: vi.fn(), - })); +vi.mock("@paperclipai/shared/telemetry", () => ({ + trackRoutineCreated: mockTrackRoutineCreated, + trackErrorHandlerCrash: vi.fn(), +})); - vi.doMock("../telemetry.js", () => ({ - getTelemetryClient: mockGetTelemetryClient, - })); +vi.mock("../telemetry.js", () => ({ + getTelemetryClient: mockGetTelemetryClient, +})); - vi.doMock("../services/index.js", () => ({ - accessService: () => mockAccessService, - logActivity: mockLogActivity, - routineService: () => mockRoutineService, - })); -} +vi.mock("../services/index.js", () => ({ + accessService: () => mockAccessService, + logActivity: mockLogActivity, + routineService: () => mockRoutineService, +})); -async function createApp(actor: Record) { - const [{ routineRoutes }, { errorHandler }] = await Promise.all([ - import("../routes/routines.js"), - import("../middleware/index.js"), - ]); +function createApp(actor: Record) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -118,9 +114,7 @@ async function createApp(actor: Record) { describe("routine routes", () => { beforeEach(() => { - vi.resetModules(); - registerRouteMocks(); - vi.clearAllMocks(); + vi.resetAllMocks(); mockGetTelemetryClient.mockReturnValue({ track: vi.fn() }); mockRoutineService.create.mockResolvedValue(routine); mockRoutineService.get.mockResolvedValue(routine); @@ -136,7 +130,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission for non-admin board routine creation", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -158,7 +152,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to retarget a routine assignee", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -179,7 +173,7 @@ describe("routine routes", () => { it("requires tasks:assign permission to reactivate a routine", async () => { mockRoutineService.get.mockResolvedValue(pausedRoutine); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -199,7 +193,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to create a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -221,7 +215,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to update a trigger", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -241,7 +235,7 @@ describe("routine routes", () => { }); it("requires tasks:assign permission to manually run a routine", async () => { - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", @@ -260,7 +254,7 @@ describe("routine routes", () => { it("allows routine creation when the board user has tasks:assign", async () => { mockAccessService.canUser.mockResolvedValue(true); - const app = await createApp({ + const app = createApp({ type: "board", userId: "board-user", source: "session", diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 785656458ed..056e812af3b 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -66,6 +66,8 @@ vi.mock("../config.js", () => ({ loadConfig: vi.fn(() => ({ deploymentMode: "authenticated", deploymentExposure: "private", + bind: "loopback", + customBindHost: undefined, host: "127.0.0.1", port: 3210, allowedHostnames: [], diff --git a/server/src/__tests__/telemetry-client-flush.test.ts b/server/src/__tests__/telemetry-client-flush.test.ts index 2264638c2fc..85826e443c4 100644 --- a/server/src/__tests__/telemetry-client-flush.test.ts +++ b/server/src/__tests__/telemetry-client-flush.test.ts @@ -76,6 +76,21 @@ describe("TelemetryClient periodic flush", () => { expect(fetch).not.toHaveBeenCalled(); }); + it("falls back to the api gateway ingest url when the default hostname fails", async () => { + vi.mocked(fetch) + .mockRejectedValueOnce(new TypeError("getaddrinfo ENOTFOUND telemetry.paperclip.ing")) + .mockResolvedValueOnce({ ok: true }); + + const client = makeClient({ endpoint: undefined }); + client.track("install.started"); + + await client.flush(); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(vi.mocked(fetch).mock.calls[0]?.[0]).toBe("https://telemetry.paperclip.ing/ingest"); + expect(vi.mocked(fetch).mock.calls[1]?.[0]).toBe("https://rusqrrg391.execute-api.us-east-1.amazonaws.com/ingest"); + }); + it("startPeriodicFlush is idempotent", () => { const client = makeClient(); client.startPeriodicFlush(1000); diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index d9201bb68be..96931b0f166 100644 --- a/server/src/adapters/utils.ts +++ b/server/src/adapters/utils.ts @@ -13,7 +13,7 @@ type BuildInvocationEnvForLogsOptions = { resolvedCommandEnvKey?: string; }; -export const runningProcesses: Map = +export const runningProcesses: Map = serverUtils.runningProcesses; export const MAX_CAPTURE_BYTES = serverUtils.MAX_CAPTURE_BYTES; export const MAX_EXCERPT_BYTES = serverUtils.MAX_EXCERPT_BYTES; diff --git a/server/src/config.ts b/server/src/config.ts index 71084cc097d..21271c980bd 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,4 +1,5 @@ import { readConfigFile } from "./config-file.js"; +import { execFileSync } from "node:child_process"; import { existsSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; import { config as loadDotenv } from "dotenv"; @@ -6,15 +7,20 @@ import { resolvePaperclipEnvPath } from "./paths.js"; import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js"; import { AUTH_BASE_URL_MODES, + BIND_MODES, DEPLOYMENT_EXPOSURES, DEPLOYMENT_MODES, SECRET_PROVIDERS, STORAGE_PROVIDERS, + type BindMode, type AuthBaseUrlMode, type DeploymentExposure, type DeploymentMode, type SecretProvider, type StorageProvider, + inferBindModeFromHost, + resolveRuntimeBind, + validateConfiguredBindMode, } from "@paperclipai/shared"; import { resolveDefaultBackupDir, @@ -39,11 +45,15 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) { maybeRepairLegacyWorktreeConfigAndEnvFiles(); +const TAILSCALE_DETECT_TIMEOUT_MS = 3000; + type DatabaseMode = "embedded-postgres" | "postgres"; export interface Config { deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; + bind: BindMode; + customBindHost: string | undefined; host: string; port: number; allowedHostnames: string[]; @@ -78,6 +88,25 @@ export interface Config { telemetryEnabled: boolean; } +function detectTailnetBindHost(): string | undefined { + const explicit = process.env.PAPERCLIP_TAILNET_BIND_HOST?.trim(); + if (explicit) return explicit; + + try { + const stdout = execFileSync("tailscale", ["ip", "-4"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: TAILSCALE_DETECT_TIMEOUT_MS, + }); + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + } catch { + return undefined; + } +} + export function loadConfig(): Config { const fileConfig = readConfigFile(); const fileDatabaseMode = @@ -148,6 +177,18 @@ export function loadConfig(): Config { deploymentMode === "local_trusted" ? "private" : (deploymentExposureFromEnv ?? fileConfig?.server.exposure ?? "private"); + const bindFromEnvRaw = process.env.PAPERCLIP_BIND; + const bindFromEnv = + bindFromEnvRaw && BIND_MODES.includes(bindFromEnvRaw as BindMode) + ? (bindFromEnvRaw as BindMode) + : null; + const configuredHost = process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1"; + const tailnetBindHost = detectTailnetBindHost(); + const bind = + bindFromEnv ?? + fileConfig?.server.bind ?? + inferBindModeFromHost(configuredHost, { tailnetBindHost }); + const customBindHost = process.env.PAPERCLIP_BIND_HOST ?? fileConfig?.server.customBindHost; const authBaseUrlModeFromEnvRaw = process.env.PAPERCLIP_AUTH_BASE_URL_MODE; const authBaseUrlModeFromEnv = authBaseUrlModeFromEnvRaw && @@ -216,18 +257,39 @@ export function loadConfig(): Config { 1, Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) || fileDatabaseBackup?.retentionDays || - 30, + 7, ); const databaseBackupDir = resolveHomeAwarePath( process.env.PAPERCLIP_DB_BACKUP_DIR ?? fileDatabaseBackup?.dir ?? resolveDefaultBackupDir(), ); + const bindValidationErrors = validateConfiguredBindMode({ + deploymentMode, + deploymentExposure, + bind, + host: configuredHost, + customBindHost, + }); + if (bindValidationErrors.length > 0) { + throw new Error(bindValidationErrors[0]); + } + const resolvedBind = resolveRuntimeBind({ + bind, + host: configuredHost, + customBindHost, + tailnetBindHost, + }); + if (resolvedBind.errors.length > 0) { + throw new Error(resolvedBind.errors[0]); + } return { deploymentMode, deploymentExposure, - host: process.env.HOST ?? fileConfig?.server.host ?? "127.0.0.1", + bind: resolvedBind.bind, + customBindHost: resolvedBind.customBindHost, + host: resolvedBind.host, port: Number(process.env.PORT) || fileConfig?.server.port || 3100, allowedHostnames, authBaseUrlMode, diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts new file mode 100644 index 00000000000..4e2b5d8db23 --- /dev/null +++ b/server/src/dev-runner-worktree.ts @@ -0,0 +1,87 @@ +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import path from "node:path"; + +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("#")) { + 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; +} + +type WorktreeEnvBootstrapResult = + | { envPath: null; missingEnv: false } + | { envPath: string; missingEnv: true } + | { envPath: string; missingEnv: false }; + +export function isLinkedGitWorktreeCheckout(rootDir: string): boolean { + const gitMetadataPath = path.join(rootDir, ".git"); + if (!existsSync(gitMetadataPath)) return false; + + const stat = lstatSync(gitMetadataPath); + if (!stat.isFile()) return false; + + return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:"); +} + +export function resolveWorktreeEnvFilePath(rootDir: string): string { + return path.resolve(rootDir, ".paperclip", ".env"); +} + +export function bootstrapDevRunnerWorktreeEnv( + rootDir: string, + env: NodeJS.ProcessEnv = process.env, +): WorktreeEnvBootstrapResult { + if (!isLinkedGitWorktreeCheckout(rootDir)) { + return { + envPath: null, + missingEnv: false, + }; + } + + const envPath = resolveWorktreeEnvFilePath(rootDir); + if (!existsSync(envPath)) { + return { + envPath, + missingEnv: true, + }; + } + + const entries = parseEnvFile(readFileSync(envPath, "utf8")); + for (const [key, value] of Object.entries(entries)) { + if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue; + env[key] = value; + } + + return { + envPath, + missingEnv: false, + }; +} diff --git a/server/src/index.ts b/server/src/index.ts index 955aaa16193..0e8db5a6291 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,6 +31,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, heartbeatService, + instanceSettingsService, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -621,20 +622,25 @@ export async function startServer(): Promise { if (config.databaseBackupEnabled) { const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; + const settingsSvc = instanceSettingsService(db); let backupInFlight = false; - + const runScheduledBackup = async () => { if (backupInFlight) { logger.warn("Skipping scheduled database backup because a previous backup is still running"); return; } - + backupInFlight = true; try { + // Read retention from Instance Settings (DB) so changes take effect without restart + const generalSettings = await settingsSvc.getGeneral(); + const retention = generalSettings.backupRetention; + const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retention, filenamePrefix: "paperclip", }); logger.info( @@ -643,7 +649,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retention, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); @@ -653,11 +659,11 @@ export async function startServer(): Promise { backupInFlight = false; } }; - + logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, - retentionDays: config.databaseBackupRetentionDays, + retentionSource: "instance-settings-db", backupDir: config.databaseBackupDir, }, "Automatic database backups enabled", @@ -695,9 +701,10 @@ export async function startServer(): Promise { logger.warn({ err, url }, "Failed to open browser on startup"); }); } - printStartupBanner({ - host: config.host, - deploymentMode: config.deploymentMode, + printStartupBanner({ + bind: config.bind, + host: config.host, + deploymentMode: config.deploymentMode, deploymentExposure: config.deploymentExposure, authReady, requestedPort: config.port, diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 49cc413aadc..dd95f691aeb 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1210,7 +1210,7 @@ function buildOnboardingDiscoveryDiagnostics(input: { code: "openclaw_onboarding_private_loopback_bind", level: "warn", message: "Paperclip is bound to loopback in authenticated/private mode.", - hint: "Run with a reachable bind host or use pnpm dev --tailscale-auth for private-network onboarding." + hint: "Use a reachable private bind mode such as `pnpm dev --bind lan` or `pnpm dev --bind tailnet` for private-network onboarding." }); } diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 0884b455df3..20f2ed534ed 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; -import { issueService } from "../services/index.js"; +import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ @@ -20,6 +20,7 @@ const createActivitySchema = z.object({ export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); + const heartbeat = heartbeatService(db); const issueSvc = issueService(db); async function resolveIssueByRef(rawId: string) { @@ -46,6 +47,7 @@ export function activityRoutes(db: Db) { router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body, @@ -80,6 +82,12 @@ export function activityRoutes(db: Db) { router.get("/heartbeat-runs/:runId/issues", async (req, res) => { const runId = req.params.runId as string; + const run = await heartbeat.getRun(runId); + if (!run) { + res.json([]); + return; + } + assertCompanyAccess(req, run.companyId); const result = await svc.issuesForRun(runId); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 39a79dd6e6e..65630f47c18 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2296,6 +2296,10 @@ export function agentRoutes(db: Db) { router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; + const existing = await heartbeat.getRun(runId); + if (existing) { + assertCompanyAccess(req, existing.companyId); + } const run = await heartbeat.cancelRun(runId); if (run) { @@ -2437,15 +2441,14 @@ export function agentRoutes(db: Db) { } assertCompanyAccess(req, issue.companyId); - let run = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null; + let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null; if (run && run.status !== "queued" && run.status !== "running") { run = null; } if (!run && issue.assigneeAgentId && issue.status === "in_progress") { - const candidateRun = await heartbeat.getActiveRunForAgent(issue.assigneeAgentId); - const candidateContext = asRecord(candidateRun?.contextSnapshot); - const candidateIssueId = asNonEmptyString(candidateContext?.issueId); + const candidateRun = await heartbeat.getActiveRunIssueSummaryForAgent(issue.assigneeAgentId); + const candidateIssueId = asNonEmptyString(candidateRun?.issueId); if (candidateRun && candidateIssueId === issue.id) { run = candidateRun; } @@ -2462,7 +2465,7 @@ export function agentRoutes(db: Db) { } res.json({ - ...redactCurrentUserValue(run, await getCurrentUserRedactionOptions()), + ...run, agentId: agent.id, agentName: agent.name, adapterType: agent.adapterType, diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 99d33abd86a..3ed20374700 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { addApprovalCommentSchema, @@ -34,6 +34,15 @@ export function approvalRoutes(db: Db) { const secretsSvc = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function requireApprovalAccess(req: Request, id: string) { + const approval = await svc.getById(id); + if (!approval) { + return null; + } + assertCompanyAccess(req, approval.companyId); + return approval; + } + router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -121,6 +130,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.approve( id, req.body.decidedByUserId ?? "board", @@ -216,6 +229,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.reject( id, req.body.decidedByUserId ?? "board", @@ -243,6 +260,10 @@ export function approvalRoutes(db: Db) { async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const approval = await svc.requestRevision( id, req.body.decidedByUserId ?? "board", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index d1978c9d6f7..22be5f863ca 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -24,7 +24,7 @@ import { logActivity, } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); @@ -48,6 +48,17 @@ export function companyRoutes(db: Db, storage?: StorageService) { return parsed; } + function assertImportTargetAccess( + req: Request, + target: { mode: "new_company" } | { mode: "existing_company"; companyId: string }, + ) { + if (target.mode === "new_company") { + assertInstanceAdmin(req); + return; + } + assertCompanyAccess(req, target.companyId); + } + async function assertCanUpdateBranding(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") return; @@ -160,18 +171,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const preview = await portability.previewImport(req.body); res.json(preview); }); router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const actor = getActorInfo(req); const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); await logActivity(db, { diff --git a/server/src/routes/costs.ts b/server/src/routes/costs.ts index 534bed6e155..46c6896fe0a 100644 --- a/server/src/routes/costs.ts +++ b/server/src/routes/costs.ts @@ -21,6 +21,26 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { fetchAllQuotaWindows } from "../services/quota-windows.js"; import { badRequest } from "../errors.js"; +export function parseCostDateRange(query: Record) { + const fromRaw = query.from as string | undefined; + const toRaw = query.to as string | undefined; + const from = fromRaw ? new Date(fromRaw) : undefined; + const to = toRaw ? new Date(toRaw) : undefined; + if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date"); + if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date"); + return (from || to) ? { from, to } : undefined; +} + +export function parseCostLimit(query: Record) { + const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit; + if (raw == null || raw === "") return 100; + const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10); + if (!Number.isFinite(limit) || limit <= 0 || limit > 500) { + throw badRequest("invalid 'limit' value"); + } + return limit; +} + export function costRoutes(db: Db) { const router = Router(); const heartbeat = heartbeatService(db); @@ -92,30 +112,10 @@ export function costRoutes(db: Db) { res.status(201).json(event); }); - function parseDateRange(query: Record) { - const fromRaw = query.from as string | undefined; - const toRaw = query.to as string | undefined; - const from = fromRaw ? new Date(fromRaw) : undefined; - const to = toRaw ? new Date(toRaw) : undefined; - if (from && isNaN(from.getTime())) throw badRequest("invalid 'from' date"); - if (to && isNaN(to.getTime())) throw badRequest("invalid 'to' date"); - return (from || to) ? { from, to } : undefined; - } - - function parseLimit(query: Record) { - const raw = Array.isArray(query.limit) ? query.limit[0] : query.limit; - if (raw == null || raw === "") return 100; - const limit = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10); - if (!Number.isFinite(limit) || limit <= 0 || limit > 500) { - throw badRequest("invalid 'limit' value"); - } - return limit; - } - router.get("/companies/:companyId/costs/summary", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const summary = await costs.summary(companyId, range); res.json(summary); }); @@ -123,7 +123,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/by-agent", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const rows = await costs.byAgent(companyId, range); res.json(rows); }); @@ -131,7 +131,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const rows = await costs.byAgentModel(companyId, range); res.json(rows); }); @@ -139,7 +139,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/by-provider", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const rows = await costs.byProvider(companyId, range); res.json(rows); }); @@ -147,7 +147,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/by-biller", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const rows = await costs.byBiller(companyId, range); res.json(rows); }); @@ -155,7 +155,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/finance-summary", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const summary = await finance.summary(companyId, range); res.json(summary); }); @@ -163,7 +163,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const rows = await finance.byBiller(companyId, range); res.json(rows); }); @@ -171,7 +171,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const rows = await finance.byKind(companyId, range); res.json(rows); }); @@ -179,8 +179,8 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/finance-events", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); - const limit = parseLimit(req.query); + const range = parseCostDateRange(req.query); + const limit = parseCostLimit(req.query); const rows = await finance.list(companyId, range, limit); res.json(rows); }); @@ -242,7 +242,7 @@ export function costRoutes(db: Db) { router.get("/companies/:companyId/costs/by-project", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const range = parseDateRange(req.query); + const range = parseCostDateRange(req.query); const rows = await costs.byProject(companyId, range); res.json(rows); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4a72bcd9062..b4a96ea292f 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -96,13 +96,6 @@ function executionPrincipalsEqual( return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId; } -function executionParticipantMatchesAgent( - participant: ParsedExecutionState["currentParticipant"] | null, - agentId: string | null | undefined, -) { - return Boolean(agentId) && participant?.type === "agent" && participant.agentId === agentId; -} - function buildExecutionStageWakeContext(input: { state: ParsedExecutionState; wakeRole: ExecutionStageWakeContext["wakeRole"]; @@ -1386,14 +1379,10 @@ export function issueRoutes( ? (updateFields.executionPolicy as NormalizedExecutionPolicy | null) : previousExecutionPolicy; - const requestedStatus = typeof updateFields.status === "string" ? updateFields.status : undefined; - const requestedAssigneePatchProvided = - req.body.assigneeAgentId !== undefined || req.body.assigneeUserId !== undefined; - const transition = applyIssueExecutionPolicyTransition({ issue: existing, policy: nextExecutionPolicy, - requestedStatus, + requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined, requestedAssigneePatch: { assigneeAgentId: req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null), @@ -1419,27 +1408,6 @@ export function issueRoutes( } Object.assign(updateFields, transition.patch); - const effectiveExecutionState = parseIssueExecutionState( - transition.patch.executionState !== undefined ? transition.patch.executionState : existing.executionState, - ); - const isUnauthorizedAgentStageMutation = - req.actor.type === "agent" && - req.actor.agentId && - existing.status === "in_review" && - transition.workflowControlledAssignment && - !transition.decision && - effectiveExecutionState?.status === "pending" && - ( - (requestedStatus !== undefined && requestedStatus !== "in_review") || - requestedAssigneePatchProvided - ) && - !executionParticipantMatchesAgent(effectiveExecutionState.currentParticipant, req.actor.agentId); - if (isUnauthorizedAgentStageMutation) { - const stageLabel = effectiveExecutionState.currentStageType ?? "execution"; - res.status(403).json({ error: `Only the active ${stageLabel} participant can update this stage` }); - return; - } - const nextAssigneeAgentId = updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null); const nextAssigneeUserId = @@ -1733,6 +1701,7 @@ export function issueRoutes( reason: "issue_assigned", payload: { issueId: issue.id, + ...(comment ? { commentId: comment.id } : {}), mutation: "update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1740,6 +1709,13 @@ export function issueRoutes( requestedByActorId: actor.actorId, contextSnapshot: { issueId: issue.id, + ...(comment + ? { + taskId: issue.id, + commentId: comment.id, + wakeCommentId: comment.id, + } + : {}), source: "issue.update", ...(interruptedRunId ? { interruptedRunId } : {}), }, @@ -1767,6 +1743,38 @@ export function issueRoutes( } if (commentBody && comment) { + const assigneeId = issue.assigneeAgentId; + const actorIsAgent = actor.actorType === "agent"; + const selfComment = actorIsAgent && actor.actorId === assigneeId; + const skipAssigneeCommentWake = selfComment || isClosed; + + if (assigneeId && !assigneeChanged && !skipAssigneeCommentWake) { + addWakeup(assigneeId, { + source: "automation", + triggerDetail: "system", + reason: reopened ? "issue_reopened_via_comment" : "issue_commented", + payload: { + issueId: id, + commentId: comment.id, + mutation: "comment", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { + issueId: id, + taskId: id, + commentId: comment.id, + wakeCommentId: comment.id, + source: reopened ? "issue.comment.reopen" : "issue.comment", + wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented", + ...(reopened ? { reopenedFrom: reopenFromStatus } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), + }, + }); + } + let mentionedIds: string[] = []; try { mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody); diff --git a/server/src/routes/llms.ts b/server/src/routes/llms.ts index 4418c76f9a3..3fec0e62093 100644 --- a/server/src/routes/llms.ts +++ b/server/src/routes/llms.ts @@ -45,6 +45,7 @@ export function llmRoutes(db: Db) { "Notes:", "- Sensitive values are redacted in configuration read APIs.", "- New hires may be created in pending_approval state depending on company settings.", + "- Timer heartbeats are opt-in for new hires. Leave runtimeConfig.heartbeat.enabled false unless the role truly needs scheduled work or the user explicitly asked for it.", "", ]; res.type("text/plain").send(lines.join("\n")); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index a86d7f68ba0..62e995307d3 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -1,6 +1,6 @@ import { and, desc, eq, isNull, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { activityLog, heartbeatRuns, issues } from "@paperclipai/db"; +import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; export interface ActivityFilters { companyId: string; @@ -66,14 +66,23 @@ export function activityService(db: Db) { runId: heartbeatRuns.id, status: heartbeatRuns.status, agentId: heartbeatRuns.agentId, + adapterType: agents.adapterType, startedAt: heartbeatRuns.startedAt, finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, usageJson: heartbeatRuns.usageJson, resultJson: heartbeatRuns.resultJson, + logBytes: heartbeatRuns.logBytes, }) .from(heartbeatRuns) + .innerJoin( + agents, + and( + eq(agents.id, heartbeatRuns.agentId), + eq(agents.companyId, heartbeatRuns.companyId), + ), + ) .where( and( eq(heartbeatRuns.companyId, companyId), diff --git a/server/src/services/heartbeat-run-summary.ts b/server/src/services/heartbeat-run-summary.ts index 441b08826ce..4d6335ef653 100644 --- a/server/src/services/heartbeat-run-summary.ts +++ b/server/src/services/heartbeat-run-summary.ts @@ -13,6 +13,34 @@ function readCommentText(value: unknown) { return trimmed.length > 0 ? trimmed : null; } +export function mergeHeartbeatRunResultJson( + resultJson: Record | null | undefined, + summary: string | null | undefined, +): Record | null { + const normalizedSummary = readCommentText(summary); + const baseResult = + resultJson && typeof resultJson === "object" && !Array.isArray(resultJson) + ? resultJson + : null; + + if (!baseResult) { + return normalizedSummary ? { summary: normalizedSummary } : null; + } + + if (!normalizedSummary) { + return baseResult; + } + + if (readCommentText(baseResult.summary)) { + return baseResult; + } + + return { + ...baseResult, + summary: normalizedSummary, + }; +} + export function summarizeHeartbeatRunResultJson( resultJson: Record | null | undefined, ): Record | null { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index bc672c77a78..f8df2badefa 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -32,7 +32,11 @@ import { companySkillService } from "./company-skills.js"; import { budgetService, type BudgetEnforcementScope } from "./budgets.js"; import { secretService } from "./secrets.js"; import { resolveDefaultAgentWorkspaceDir, resolveManagedProjectWorkspaceDir } from "../home-paths.js"; -import { buildHeartbeatRunIssueComment, summarizeHeartbeatRunResultJson } from "./heartbeat-run-summary.js"; +import { + buildHeartbeatRunIssueComment, + mergeHeartbeatRunResultJson, + summarizeHeartbeatRunResultJson, +} from "./heartbeat-run-summary.js"; import { buildWorkspaceReadyComment, cleanupExecutionWorkspaceArtifacts, @@ -47,6 +51,7 @@ import { import { issueService } from "./issues.js"; import { executionWorkspaceService, mergeExecutionWorkspaceConfig } from "./execution-workspaces.js"; import { workspaceOperationService } from "./workspace-operations.js"; +import { isProcessGroupAlive, terminateLocalService } from "./local-service-supervisor.js"; import { buildExecutionWorkspaceAdapterConfig, gateProjectExecutionWorkspacePolicy, @@ -261,6 +266,9 @@ async function ensureManagedProjectWorkspace(input: { } } +const heartbeatRunProcessGroupIdColumn = + heartbeatRuns.processGroupId ?? sql`NULL`.as("processGroupId"); + const heartbeatRunListColumns = { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, @@ -288,6 +296,7 @@ const heartbeatRunListColumns = { errorCode: heartbeatRuns.errorCode, externalRunId: heartbeatRuns.externalRunId, processPid: heartbeatRuns.processPid, + processGroupId: heartbeatRunProcessGroupIdColumn, processStartedAt: heartbeatRuns.processStartedAt, retryOfRunId: heartbeatRuns.retryOfRunId, processLossRetryCount: heartbeatRuns.processLossRetryCount, @@ -296,6 +305,18 @@ const heartbeatRunListColumns = { updatedAt: heartbeatRuns.updatedAt, } as const; +const heartbeatRunIssueSummaryColumns = { + id: heartbeatRuns.id, + status: heartbeatRuns.status, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + agentId: heartbeatRuns.agentId, + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"), +} as const; + function appendExcerpt(prev: string, chunk: string) { return appendWithCap(prev, chunk, MAX_EXCERPT_BYTES); } @@ -707,6 +728,18 @@ export function shouldResetTaskSessionForWake( return false; } +function shouldRequireIssueCommentForWake( + contextSnapshot: Record | null | undefined, +) { + const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason); + return ( + wakeReason === "issue_assigned" || + wakeReason === "execution_review_requested" || + wakeReason === "execution_approval_requested" || + wakeReason === "execution_changes_requested" + ); +} + export function formatRuntimeWorkspaceWarningLog(warning: string) { return { stream: "stdout" as const, @@ -1014,6 +1047,46 @@ function isProcessAlive(pid: number | null | undefined) { } } +async function terminateHeartbeatRunProcess(input: { + pid: number | null | undefined; + processGroupId: number | null | undefined; + graceMs?: number; +}) { + const pid = input.pid ?? null; + const processGroupId = input.processGroupId ?? null; + if (typeof pid !== "number" && typeof processGroupId !== "number") return; + + await terminateLocalService( + { + pid: + typeof pid === "number" && Number.isInteger(pid) && pid > 0 + ? pid + : (processGroupId ?? 0), + processGroupId: + typeof processGroupId === "number" && Number.isInteger(processGroupId) && processGroupId > 0 + ? processGroupId + : null, + }, + input.graceMs ? { forceAfterMs: input.graceMs } : undefined, + ); +} + +function buildProcessLossMessage(run: { + processPid: number | null; + processGroupId: number | null; +}, options?: { descendantOnly?: boolean }) { + if (options?.descendantOnly && run.processGroupId) { + return `Process lost -- parent pid ${run.processPid ?? "unknown"} exited, but descendant process group ${run.processGroupId} was still alive and was terminated`; + } + if (run.processPid) { + return `Process lost -- child pid ${run.processPid} is no longer running`; + } + if (run.processGroupId) { + return `Process lost -- process group ${run.processGroupId} is no longer running`; + } + return "Process lost -- server may have restarted"; +} + function truncateDisplayId(value: string | null | undefined, max = 128) { if (!value) return null; return value.length > max ? value.slice(0, max) : value; @@ -1812,13 +1885,14 @@ export function heartbeatService(db: Db) { async function persistRunProcessMetadata( runId: string, - meta: { pid: number; startedAt: string }, + meta: { pid: number; processGroupId: number | null; startedAt: string }, ) { const startedAt = new Date(meta.startedAt); return db .update(heartbeatRuns) .set({ processPid: meta.pid, + processGroupId: meta.processGroupId, processStartedAt: Number.isNaN(startedAt.getTime()) ? new Date() : startedAt, updatedAt: new Date(), }) @@ -2011,18 +2085,6 @@ export function heartbeatService(db: Db) { return { outcome: "not_applicable" as const, queuedRun: null }; } - const wakeReason = readNonEmptyString(contextSnapshot.wakeReason); - if (wakeReason === "issue_commented" || wakeReason === "issue_comment_mentioned" || wakeReason === "issue_reopened_via_comment") { - if (run.issueCommentStatus !== "not_applicable") { - await patchRunIssueCommentStatus(run.id, { - issueCommentStatus: "not_applicable", - issueCommentSatisfiedByCommentId: null, - issueCommentRetryQueuedAt: null, - }); - } - return { outcome: "not_applicable" as const, queuedRun: null }; - } - const postedComment = await findRunIssueComment(run.id, run.companyId, issueId); if (postedComment) { await patchRunIssueCommentStatus(run.id, { @@ -2047,6 +2109,17 @@ export function heartbeatService(db: Db) { return { outcome: "retry_exhausted" as const, queuedRun: null }; } + if (!shouldRequireIssueCommentForWake(contextSnapshot)) { + if (run.issueCommentStatus !== "not_applicable") { + await patchRunIssueCommentStatus(run.id, { + issueCommentStatus: "not_applicable", + issueCommentSatisfiedByCommentId: null, + issueCommentRetryQueuedAt: null, + }); + } + return { outcome: "not_applicable" as const, queuedRun: null }; + } + const queuedRun = await enqueueMissingIssueCommentRetry(run, agent, issueId); if (queuedRun) { await appendRunEvent(run, await nextRunEventSeq(run.id), { @@ -2345,7 +2418,9 @@ export function heartbeatService(db: Db) { } const tracksLocalChild = isTrackedLocalChildProcessAdapter(adapterType); - if (tracksLocalChild && run.processPid && isProcessAlive(run.processPid)) { + const processPidAlive = tracksLocalChild && run.processPid && isProcessAlive(run.processPid); + const processGroupAlive = tracksLocalChild && run.processGroupId && isProcessGroupAlive(run.processGroupId); + if (processPidAlive) { if (run.errorCode !== DETACHED_PROCESS_ERROR_CODE) { const detachedMessage = `Lost in-memory process handle, but child pid ${run.processPid} is still alive`; const detachedRun = await setRunStatus(run.id, "running", { @@ -2367,10 +2442,17 @@ export function heartbeatService(db: Db) { continue; } - const shouldRetry = tracksLocalChild && !!run.processPid && (run.processLossRetryCount ?? 0) < 1; - const baseMessage = run.processPid - ? `Process lost -- child pid ${run.processPid} is no longer running` - : "Process lost -- server may have restarted"; + let descendantOnlyCleanup = false; + if (processGroupAlive) { + descendantOnlyCleanup = true; + await terminateHeartbeatRunProcess({ + pid: run.processPid, + processGroupId: run.processGroupId, + }); + } + + const shouldRetry = tracksLocalChild && (!!run.processPid || !!run.processGroupId) && (run.processLossRetryCount ?? 0) < 1; + const baseMessage = buildProcessLossMessage(run, descendantOnlyCleanup ? { descendantOnly: true } : undefined); let finalizedRun = await setRunStatus(run.id, "failed", { error: shouldRetry ? `${baseMessage}; retrying once` : baseMessage, @@ -2403,6 +2485,8 @@ export function heartbeatService(db: Db) { : baseMessage, payload: { ...(run.processPid ? { processPid: run.processPid } : {}), + ...(run.processGroupId ? { processGroupId: run.processGroupId } : {}), + ...(descendantOnlyCleanup ? { descendantOnlyCleanup: true } : {}), ...(retriedRun ? { retryRunId: retriedRun.id } : {}), }, }); @@ -3168,7 +3252,14 @@ export function heartbeatService(db: Db) { onLog, onMeta: onAdapterMeta, onSpawn: async (meta) => { - await persistRunProcessMetadata(run.id, meta); + await persistRunProcessMetadata(run.id, { + pid: meta.pid, + processGroupId: + "processGroupId" in meta && typeof meta.processGroupId === "number" + ? meta.processGroupId + : null, + startedAt: meta.startedAt, + }); }, authToken: authToken ?? undefined, }); @@ -3288,6 +3379,11 @@ export function heartbeatService(db: Db) { } as Record) : null; + const persistedResultJson = mergeHeartbeatRunResultJson( + adapterResult.resultJson ?? null, + adapterResult.summary ?? null, + ); + await setRunStatus(run.id, status, { finishedAt: new Date(), error: @@ -3308,7 +3404,7 @@ export function heartbeatService(db: Db) { exitCode: adapterResult.exitCode, signal: adapterResult.signal, usageJson, - resultJson: adapterResult.resultJson ?? null, + resultJson: persistedResultJson, sessionIdAfter: nextSessionState.displayId ?? nextSessionState.legacySessionId, stdoutExcerpt, stderrExcerpt, @@ -3336,7 +3432,7 @@ export function heartbeatService(db: Db) { }); if (issueId && outcome === "succeeded") { try { - const issueComment = buildHeartbeatRunIssueComment(adapterResult.resultJson ?? null); + const issueComment = buildHeartbeatRunIssueComment(persistedResultJson); if (issueComment) { await issuesSvc.addComment(issueId, issueComment, { agentId: agent.id, runId: finalizedRun.id }); } @@ -4231,13 +4327,16 @@ export function heartbeatService(db: Db) { const running = runningProcesses.get(run.id); if (running) { - running.child.kill("SIGTERM"); - const graceMs = Math.max(1, running.graceSec) * 1000; - setTimeout(() => { - if (!running.child.killed) { - running.child.kill("SIGKILL"); - } - }, graceMs); + await terminateHeartbeatRunProcess({ + pid: running.child.pid ?? run.processPid, + processGroupId: running.processGroupId ?? run.processGroupId, + graceMs: Math.max(1, running.graceSec) * 1000, + }); + } else if (run.processPid || run.processGroupId) { + await terminateHeartbeatRunProcess({ + pid: run.processPid, + processGroupId: run.processGroupId, + }); } const cancelled = await setRunStatus(run.id, "cancelled", { @@ -4287,8 +4386,17 @@ export function heartbeatService(db: Db) { const running = runningProcesses.get(run.id); if (running) { - running.child.kill("SIGTERM"); + await terminateHeartbeatRunProcess({ + pid: running.child.pid ?? run.processPid, + processGroupId: running.processGroupId ?? run.processGroupId, + graceMs: Math.max(1, running.graceSec) * 1000, + }); runningProcesses.delete(run.id); + } else if (run.processPid || run.processGroupId) { + await terminateHeartbeatRunProcess({ + pid: run.processPid, + processGroupId: run.processGroupId, + }); } await releaseIssueExecutionAndPromote(run); } @@ -4504,6 +4612,15 @@ export function heartbeatService(db: Db) { cancelBudgetScopeWork, + getRunIssueSummary: async (runId: string) => { + const [run] = await db + .select(heartbeatRunIssueSummaryColumns) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .limit(1); + return run ?? null; + }, + getActiveRunForAgent: async (agentId: string) => { const [run] = await db .select() @@ -4518,5 +4635,20 @@ export function heartbeatService(db: Db) { .limit(1); return run ?? null; }, + + getActiveRunIssueSummaryForAgent: async (agentId: string) => { + const [run] = await db + .select(heartbeatRunIssueSummaryColumns) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.agentId, agentId), + eq(heartbeatRuns.status, "running"), + ), + ) + .orderBy(desc(heartbeatRuns.startedAt)) + .limit(1); + return run ?? null; + }, }; } diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 7856591d689..65a1263226d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,6 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + DEFAULT_BACKUP_RETENTION, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -22,12 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetention: DEFAULT_BACKUP_RETENTION, }; } diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 6f4ba7b5557..6a3045a1e05 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -393,13 +393,19 @@ export function applyIssueExecutionPolicyTransition(input: TransitionInput): Tra } } - if ( + const attemptedStageAdvance = + (requestedStatus !== undefined && requestedStatus !== "in_review") || + (requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)); + const stageStateDrifted = input.issue.status !== "in_review" || !principalsEqual(currentAssignee, currentParticipant) || - !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant) || - (requestedStatus !== undefined && requestedStatus !== "in_review") || - (requestedAssigneePatchProvided && !principalsEqual(explicitAssignee, currentParticipant)) - ) { + !principalsEqual(existingState?.currentParticipant ?? null, currentParticipant); + + if (attemptedStageAdvance && !stageStateDrifted) { + throw unprocessable("Only the active reviewer or approver can advance the current execution stage"); + } + + if (stageStateDrifted) { buildPendingStagePatch({ patch, previous: existingState, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index bb40be79d3e..f7ac19da0df 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -997,8 +997,8 @@ export function issueService(db: Db) { WHEN ${titleContainsMatch} THEN 1 WHEN ${identifierStartsWithMatch} THEN 2 WHEN ${identifierContainsMatch} THEN 3 - WHEN ${descriptionContainsMatch} THEN 4 - WHEN ${commentContainsMatch} THEN 5 + WHEN ${commentContainsMatch} THEN 4 + WHEN ${descriptionContainsMatch} THEN 5 ELSE 6 END `; diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 68dbbdc89df..85c9c16f6de 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -197,6 +197,17 @@ export function isPidAlive(pid: number) { } } +export function isProcessGroupAlive(processGroupId: number | null | undefined) { + if (process.platform === "win32") return false; + if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false; + try { + process.kill(-processGroupId, 0); + return true; + } catch { + return false; + } +} + async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { if (process.platform === "win32") return true; try { @@ -269,13 +280,19 @@ export async function terminateLocalService( const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000); while (Date.now() < deadline) { - if (!isPidAlive(record.pid)) { + const targetAlive = targetProcessGroup + ? isProcessGroupAlive(record.processGroupId) + : isPidAlive(record.pid); + if (!targetAlive) { return; } await delay(100); } - if (!isPidAlive(record.pid)) return; + const stillAlive = targetProcessGroup + ? isProcessGroupAlive(record.processGroupId) + : isPidAlive(record.pid); + if (!stillAlive) return; try { if (targetProcessGroup) { process.kill(-record.processGroupId!, "SIGKILL"); diff --git a/server/src/startup-banner.ts b/server/src/startup-banner.ts index 1a52731b39d..a3ae2ed1a33 100644 --- a/server/src/startup-banner.ts +++ b/server/src/startup-banner.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; -import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; +import type { BindMode, DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { parse as parseEnvFileContents } from "dotenv"; @@ -18,6 +18,7 @@ type EmbeddedPostgresInfo = { }; type StartupBannerOptions = { + bind: BindMode; host: string; deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; @@ -148,6 +149,7 @@ export function printStartupBanner(opts: StartupBannerOptions): void { color(" ───────────────────────────────────────────────────────", "blue"), row("Mode", `${dbMode} | ${uiMode}`), row("Deploy", `${opts.deploymentMode} (${opts.deploymentExposure})`), + row("Bind", `${opts.bind} ${color(`(${opts.host})`, "dim")}`), row("Auth", opts.authReady ? color("ready", "green") : color("not-ready", "yellow")), row("Server", portValue), row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`), diff --git a/skills/paperclip-create-agent/SKILL.md b/skills/paperclip-create-agent/SKILL.md index d4f73aea415..e3178d53aeb 100644 --- a/skills/paperclip-create-agent/SKILL.md +++ b/skills/paperclip-create-agent/SKILL.md @@ -63,6 +63,7 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \ - adapter type - optional `desiredSkills` from the company skill library when this role needs installed skills on day one - adapter and runtime config aligned to this environment +- leave timer heartbeats off by default; only set `runtimeConfig.heartbeat.enabled=true` with an `intervalSec` when the role genuinely needs scheduled recurring work or the user explicitly asked for it - capabilities - run prompt in adapter config (`promptTemplate` where applicable) - source issue linkage (`sourceIssueId` or `sourceIssueIds`) when this hire came from an issue @@ -83,7 +84,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h "desiredSkills": ["vercel-labs/agent-browser/agent-browser"], "adapterType": "codex_local", "adapterConfig": {"cwd": "/abs/path/to/repo", "model": "o4-mini"}, - "runtimeConfig": {"heartbeat": {"enabled": true, "intervalSec": 300, "wakeOnDemand": true}}, + "runtimeConfig": {"heartbeat": {"enabled": false, "wakeOnDemand": true}}, "sourceIssueId": "" }' ``` @@ -136,6 +137,7 @@ Before sending a hire request: - Avoid secrets in plain text unless required by adapter behavior. - Ensure reporting line is correct and in-company. - Ensure prompt is role-specific and operationally scoped. +- Keep timer heartbeats opt-in. Most hires should rely on assignment/on-demand wakeups unless the job explicitly needs a schedule. - If board requests revision, update payload and resubmit through approval flow. For endpoint payload shapes and full examples, read: diff --git a/skills/paperclip-create-agent/references/api-reference.md b/skills/paperclip-create-agent/references/api-reference.md index baea613838e..f14177be9a0 100644 --- a/skills/paperclip-create-agent/references/api-reference.md +++ b/skills/paperclip-create-agent/references/api-reference.md @@ -47,8 +47,7 @@ Request body matches agent create shape: }, "runtimeConfig": { "heartbeat": { - "enabled": true, - "intervalSec": 300, + "enabled": false, "wakeOnDemand": true } }, @@ -80,6 +79,7 @@ Response: If company setting disables required approval, `approval` is `null` and the agent is created as `idle`. `desiredSkills` accepts company skill ids, canonical keys, or a unique slug. The server resolves and stores canonical company skill keys. +Leave timer heartbeats disabled by default. Only set `runtimeConfig.heartbeat.enabled=true` and include an `intervalSec` when the role truly needs scheduled recurring work or the user explicitly requested it. ## Approval Lifecycle diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index c8e3ad5f861..e6730574515 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -120,6 +120,17 @@ Headers: X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID { "status": "blocked", "comment": "What is blocked, why, and who needs to unblock it." } ``` +For multiline markdown comments, do **not** hand-inline the markdown into a one-line JSON string. That is how comments get "smooshed" together. Use the helper below or an equivalent `jq --arg` pattern so literal newlines survive JSON encoding: + +```bash +scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status done <<'MD' +Done + +- Fixed the newline-preserving issue update path +- Verified the raw stored comment body keeps paragraph breaks +MD +``` + 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`, `blockedByIssueIds`. **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. @@ -234,6 +245,20 @@ Never leave bare ticket ids in issue descriptions or comments when a clickable i Do NOT use unprefixed paths like `/issues/PAP-123` or `/agents/cto` — always include the company prefix. +**Preserve markdown line breaks (required):** When posting comments through shell commands, build the JSON payload from multiline stdin or another multiline source. Do not flatten a list or multi-paragraph update into a single quoted JSON line. Preferred helper: + +```bash +scripts/paperclip-issue-update.sh --issue-id "$PAPERCLIP_TASK_ID" --status in_progress <<'MD' +Investigating comment formatting + +- Pulled the raw stored comment body +- Compared it with the run's final assistant message +- Traced whether the flattening happened before or after the API call +MD +``` + +If you cannot use the helper, use `jq -n --arg comment "$comment"` with `comment` read from a heredoc or file. Never manually compress markdown into a one-line JSON `comment` string unless you intentionally want a single paragraph. + Example: ```md diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 1b34624d0fc..49299bea597 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -579,6 +579,7 @@ POST /api/companies/{companyId}/agent-hires If company policy requires approval, the new agent is created as `pending_approval` and a linked `hire_agent` approval is created automatically. **Do NOT** request hires unless you are a manager or CEO. IC agents should ask their manager. +Leave timer heartbeats off by default for new hires. Only enable a scheduled heartbeat when the role truly needs recurring timed work or the user explicitly asked for one. Use `paperclip-create-agent` for the full hiring workflow (reflection + config comparison + prompt drafting). diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 86bef6009b7..b1a24bb9244 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -7,6 +7,10 @@ import { } from "../../components/agent-config-primitives"; import { ChoosePathButton } from "../../components/PathInstructionsModal"; import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields"; +import { + CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, + isCodexLocalFastModeSupported, +} from "@paperclipai/adapter-codex-local"; 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"; @@ -27,6 +31,14 @@ export function CodexLocalConfigFields({ }: AdapterConfigFieldsProps) { const bypassEnabled = config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true; + const fastModeEnabled = isCreate + ? Boolean(values!.fastMode) + : eff("adapterConfig", "fastMode", Boolean(config.fastMode)); + const currentModel = isCreate + ? String(values!.model ?? "") + : eff("adapterConfig", "model", String(config.model ?? "")); + const fastModeSupported = isCodexLocalFastModeSupported(currentModel); + const supportedModelsLabel = CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", "); return ( <> @@ -88,6 +100,23 @@ export function CodexLocalConfigFields({ : mark("adapterConfig", "search", v) } /> + + isCreate + ? set!({ fastMode: v }) + : mark("adapterConfig", "fastMode", v) + } + /> + {fastModeEnabled && ( +
+ {fastModeSupported + ? "Fast mode consumes credits/tokens much faster than standard Codex runs." + : `Fast mode currently only works on ${supportedModelsLabel}. Paperclip will ignore this toggle until the model is switched.`} +
+ )} { expect(first).toEqual([]); expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]); }); + + it("converts parser failures into transcript error entries and keeps going", () => { + const entries = buildTranscript( + [ + { ts, stream: "stdout", chunk: "ok\nexplode\nlater\n" }, + ], + (line, entryTs) => { + if (line === "explode") { + throw new Error("boom"); + } + return [{ kind: "stdout", ts: entryTs, text: line }]; + }, + ); + + expect(entries).toEqual([ + { kind: "stdout", ts, text: "ok" }, + { + kind: "result", + ts, + text: "Chat transcript error: boom. Falling back for line: explode", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }, + { kind: "stdout", ts, text: "later" }, + ]); + }); + + it("resets stateful parsers after a failure before parsing later lines", () => { + const statefulAdapter: UIAdapterModule = { + type: "stateful_test", + label: "Stateful Test", + parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }], + createStdoutParser: () => { + let pending: string | null = null; + return { + parseLine: (line, entryTs) => { + if (line.startsWith("begin:")) { + pending = line.slice("begin:".length); + return []; + } + if (line === "explode") { + throw new Error(`bad state:${pending ?? "none"}`); + } + if (line === "finish" && pending) { + const text = `completed:${pending}`; + pending = null; + return [{ kind: "stdout", ts: entryTs, text }]; + } + return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }]; + }, + reset: () => { + pending = null; + }, + }; + }, + ConfigFields: () => null, + buildAdapterConfig: () => ({}), + }; + + const entries = buildTranscript( + [{ ts, stream: "stdout", chunk: "begin:task-a\nexplode\nfinish\n" }], + statefulAdapter, + ); + + expect(entries).toEqual([ + { + kind: "result", + ts, + text: "Chat transcript error: bad state:task-a. Falling back for line: explode", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }, + { kind: "stdout", ts, text: "literal:finish" }, + ]); + }); + + it("handles trailing buffered parser failures without throwing", () => { + const entries = buildTranscript( + [{ ts, stream: "stdout", chunk: "explode" }], + (line, entryTs) => { + if (line === "explode") { + throw new Error("trailing boom"); + } + return [{ kind: "stdout", ts: entryTs, text: line }]; + }, + ); + + expect(entries).toEqual([ + { + kind: "result", + ts, + text: "Chat transcript error: trailing boom. Falling back for line: explode", + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }, + ]); + }); }); diff --git a/ui/src/adapters/transcript.ts b/ui/src/adapters/transcript.ts index 307aa5ae44b..95a81707dde 100644 --- a/ui/src/adapters/transcript.ts +++ b/ui/src/adapters/transcript.ts @@ -3,6 +3,7 @@ import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from " export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string }; type TranscriptBuildOptions = { censorUsernameInLogs?: boolean }; +type RedactionOptions = { enabled: boolean }; function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) { if (typeof source === "function") { @@ -33,6 +34,66 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr } } +function truncateTranscriptLine(line: string, maxLength = 160) { + if (line.length <= maxLength) return line; + return `${line.slice(0, maxLength - 3)}...`; +} + +function formatTranscriptParserError(error: unknown) { + if (error instanceof Error && error.message) return error.message; + if (typeof error === "string" && error) return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function createTranscriptParseErrorEntry( + line: string, + ts: string, + error: unknown, + redactionOptions: RedactionOptions, +): TranscriptEntry { + const errorText = formatTranscriptParserError(error) || "unknown parser error"; + const preview = truncateTranscriptLine(line); + return { + kind: "result", + ts, + text: redactHomePathUserSegments( + `Chat transcript error: ${errorText}. Falling back for line: ${preview}`, + redactionOptions, + ), + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + costUsd: 0, + subtype: "transcript_parse_error", + isError: true, + errors: [], + }; +} + +function appendParsedTranscriptLine(args: { + entries: TranscriptEntry[]; + line: string; + ts: string; + parseLine: (line: string, ts: string) => TranscriptEntry[]; + reset: (() => void) | null; + redactionOptions: RedactionOptions; +}) { + const { entries, line, ts, parseLine, reset, redactionOptions } = args; + try { + appendTranscriptEntries( + entries, + parseLine(line, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)), + ); + } catch (error) { + reset?.(); + appendTranscriptEntry(entries, createTranscriptParseErrorEntry(line, ts, error, redactionOptions)); + } +} + export function buildTranscript( chunks: RunLogChunk[], parserSource: StdoutLineParser | TranscriptParserSource, @@ -59,14 +120,28 @@ export function buildTranscript( for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; - appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendParsedTranscriptLine({ + entries, + line: trimmed, + ts: chunk.ts, + parseLine, + reset, + redactionOptions, + }); } } const trailing = stdoutBuffer.trim(); if (trailing) { const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString(); - appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions))); + appendParsedTranscriptLine({ + entries, + line: trailing, + ts, + parseLine, + reset, + redactionOptions, + }); } reset?.(); diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index b1f43d49dfa..46f887ae28e 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -5,12 +5,14 @@ export interface RunForIssue { runId: string; status: string; agentId: string; + adapterType: string; startedAt: string | null; finishedAt: string | null; createdAt: string; invocationSource: string; usageJson: Record | null; resultJson: Record | null; + logBytes?: number | null; } export interface IssueForRun { diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index faabdcf1fe7..045c2b1f2f1 100644 --- a/ui/src/api/heartbeats.ts +++ b/ui/src/api/heartbeats.ts @@ -1,15 +1,18 @@ -import type { - HeartbeatRun, - HeartbeatRunEvent, - InstanceSchedulerHeartbeatAgent, - WorkspaceOperation, -} from "@paperclipai/shared"; +import type { HeartbeatRun, HeartbeatRunEvent, InstanceSchedulerHeartbeatAgent, WorkspaceOperation } from "@paperclipai/shared"; import { api } from "./client"; -export interface ActiveRunForIssue extends HeartbeatRun { +export interface ActiveRunForIssue { + id: string; + status: string; + invocationSource: string; + triggerDetail: string | null; + startedAt: string | Date | null; + finishedAt: string | Date | null; + createdAt: string | Date; agentId: string; agentName: string; adapterType: string; + issueId?: string | null; } export interface LiveRunForIssue { diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index 26c969e3c14..feca6161a84 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -30,8 +30,8 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { const runs = liveRuns ?? []; const { data: issues } = useQuery({ - queryKey: queryKeys.issues.list(companyId), - queryFn: () => issuesApi.list(companyId), + queryKey: [...queryKeys.issues.list(companyId), "with-routine-executions"], + queryFn: () => issuesApi.list(companyId, { includeRoutineExecutions: true }), enabled: runs.length > 0, }); diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index dd74c37626a..065405f9a2d 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -49,6 +49,7 @@ import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-confi import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { getAdapterLabel } from "../adapters/adapter-display-registry"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; +import { buildAgentUpdatePatch, type AgentConfigOverlay } from "../lib/agent-config-patch"; /* ---- Create mode values ---- */ @@ -89,15 +90,7 @@ type AgentConfigFormProps = { /* ---- Edit mode overlay (dirty tracking) ---- */ -interface Overlay { - identity: Record; - adapterType?: string; - adapterConfig: Record; - heartbeat: Record; - runtime: Record; -} - -const emptyOverlay: Overlay = { +const emptyOverlay: AgentConfigOverlay = { identity: {}, adapterConfig: {}, heartbeat: {}, @@ -107,7 +100,7 @@ const emptyOverlay: Overlay = { /** Stable empty object used as fallback for missing env config to avoid new-object-per-render. */ const EMPTY_ENV: Record = {}; -function isOverlayDirty(o: Overlay): boolean { +function isOverlayDirty(o: AgentConfigOverlay): boolean { return ( Object.keys(o.identity).length > 0 || o.adapterType !== undefined || @@ -211,7 +204,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { }); // ---- Edit mode: overlay for dirty tracking ---- - const [overlay, setOverlay] = useState(emptyOverlay); + const [overlay, setOverlay] = useState(emptyOverlay); const agentRef = useRef(null); // Clear overlay when agent data refreshes (after save) @@ -227,14 +220,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const isDirty = !isCreate && isOverlayDirty(overlay); /** Read effective value: overlay if dirty, else original */ - function eff(group: keyof Omit, field: string, original: T): T { + function eff(group: keyof Omit, field: string, original: T): T { const o = overlay[group]; if (field in o) return o[field] as T; return original; } /** Mark field dirty in overlay */ - function mark(group: keyof Omit, field: string, value: unknown) { + function mark(group: keyof Omit, field: string, value: unknown) { setOverlay((prev) => ({ ...prev, [group]: { ...prev[group], [field]: value }, @@ -248,48 +241,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const handleSave = useCallback(() => { if (isCreate || !isDirty) return; - const agent = props.agent; - const patch: Record = {}; - - if (Object.keys(overlay.identity).length > 0) { - Object.assign(patch, overlay.identity); - } - if (overlay.adapterType !== undefined) { - patch.adapterType = overlay.adapterType; - // 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 }; - } - if (Object.keys(overlay.heartbeat).length > 0) { - const existingRc = (agent.runtimeConfig ?? {}) as Record; - const existingHb = (existingRc.heartbeat ?? {}) as Record; - patch.runtimeConfig = { ...existingRc, heartbeat: { ...existingHb, ...overlay.heartbeat } }; - } - if (Object.keys(overlay.runtime).length > 0) { - Object.assign(patch, overlay.runtime); - } - - props.onSave(patch); + props.onSave(buildAgentUpdatePatch(props.agent, overlay)); }, [isCreate, isDirty, overlay, props]); useEffect(() => { diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 911a109a4be..b10f69e6504 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -19,9 +19,9 @@ const roleLabels = AGENT_ROLE_LABELS as Record; function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -68,7 +68,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { )} {runtimeState?.lastError && ( - {runtimeState.lastError} + {runtimeState.lastError} )} {agent.lastHeartbeatAt && ( diff --git a/ui/src/components/CommandPalette.test.tsx b/ui/src/components/CommandPalette.test.tsx new file mode 100644 index 00000000000..b229cc96739 --- /dev/null +++ b/ui/src/components/CommandPalette.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CommandPalette } from "./CommandPalette"; + +const companyState = vi.hoisted(() => ({ + selectedCompanyId: "company-1", +})); + +const dialogState = vi.hoisted(() => ({ + openNewIssue: vi.fn(), + openNewAgent: vi.fn(), +})); + +const sidebarState = vi.hoisted(() => ({ + isMobile: false, + setSidebarOpen: vi.fn(), +})); + +const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockAgentsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +const mockProjectsApi = vi.hoisted(() => ({ + list: vi.fn(), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => companyState, +})); + +vi.mock("../context/DialogContext", () => ({ + useDialog: () => dialogState, +})); + +vi.mock("../context/SidebarContext", () => ({ + useSidebar: () => sidebarState, +})); + +vi.mock("@/lib/router", () => ({ + useNavigate: () => vi.fn(), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +vi.mock("../api/agents", () => ({ + agentsApi: mockAgentsApi, +})); + +vi.mock("../api/projects", () => ({ + projectsApi: mockProjectsApi, +})); + +vi.mock("./Identity", () => ({ + Identity: ({ name }: { name: string }) => {name}, +})); + +vi.mock("@/components/ui/command", () => ({ + CommandDialog: ({ open, children }: { open: boolean; children: ReactNode }) => (open ?
{children}
: null), + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandGroup: ({ children }: { children: ReactNode }) =>
{children}
, + CommandInput: ({ + value, + onValueChange, + }: { + value: string; + onValueChange: (value: string) => void; + }) => ( +
+ onValueChange(event.currentTarget.value)} + /> +
+ ), + CommandItem: ({ + children, + onSelect, + }: { + children: ReactNode; + onSelect?: () => void; + }) => , + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandSeparator: () =>
, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flush() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function waitForAssertion(assertion: () => void, attempts = 20) { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await flush(); + } + } + throw lastError; +} + +function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + act(() => { + root.render( + + {node} + , + ); + }); + + return { root, queryClient }; +} + +describe("CommandPalette", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + dialogState.openNewIssue.mockReset(); + dialogState.openNewAgent.mockReset(); + sidebarState.setSidebarOpen.mockReset(); + mockIssuesApi.list.mockReset(); + mockAgentsApi.list.mockReset(); + mockProjectsApi.list.mockReset(); + mockIssuesApi.list.mockResolvedValue([]); + mockAgentsApi.list.mockResolvedValue([]); + mockProjectsApi.list.mockResolvedValue([]); + }); + + afterEach(() => { + container.remove(); + }); + + it("includes routine execution issues in search queries", async () => { + const { root } = renderWithQueryClient(, container); + + act(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true, bubbles: true })); + }); + + const setQueryButton = container.querySelector('button[aria-label="Set query"]'); + expect(setQueryButton).not.toBeNull(); + + act(() => { + setQueryButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitForAssertion(() => { + expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", { + q: "pull/3303", + limit: 10, + includeRoutineExecutions: true, + }); + }); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 82987bf6547..f5a0ef750b9 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -65,7 +65,7 @@ export function CommandPalette() { const { data: searchedIssues = [] } = useQuery({ queryKey: queryKeys.issues.search(selectedCompanyId!, searchQuery, undefined, 10), - queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10 }), + queryFn: () => issuesApi.list(selectedCompanyId!, { q: searchQuery, limit: 10, includeRoutineExecutions: true }), enabled: !!selectedCompanyId && open && searchQuery.length > 0, }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 062cbcf7a34..f4ad17b5233 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "react-router-dom"; import type { Agent, @@ -369,7 +369,7 @@ function CommentCard({
- {comment.body} + {comment.body} {companyId && !isPending ? (
(null); const [votingTargetId, setVotingTargetId] = useState(null); + const editorRef = useRef(null); + const attachInputRef = useRef(null); + const draftTimer = useRef | null>(null); const location = useLocation(); const hasScrolledRef = useRef(false); @@ -730,6 +738,29 @@ export const CommentThread = memo(function CommentThread({ })); }, [agentMap, providedMentions]); + useEffect(() => { + if (!draftKey) return; + setBody(loadDraft(draftKey)); + }, [draftKey]); + + useEffect(() => { + if (!draftKey) return; + if (draftTimer.current) clearTimeout(draftTimer.current); + draftTimer.current = setTimeout(() => { + saveDraft(draftKey, body); + }, DRAFT_DEBOUNCE_MS); + }, [body, draftKey]); + + useEffect(() => { + return () => { + if (draftTimer.current) clearTimeout(draftTimer.current); + }; + }, []); + + useEffect(() => { + setReassignTarget(effectiveSuggestedAssigneeValue); + }, [effectiveSuggestedAssigneeValue]); + // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { const hash = location.hash; @@ -748,25 +779,72 @@ export const CommentThread = memo(function CommentThread({ } }, [location.hash, comments, queuedComments]); - const handleFeedbackVote = useCallback( - async ( - commentId: string, - vote: FeedbackVoteValue, - options?: { allowSharing?: boolean; reason?: string }, - ) => { - if (!onVote) return; - setVotingTargetId(commentId); - try { - await onVote(commentId, vote, options); - } finally { - setVotingTargetId(null); + 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(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); + } + } + + async function handleAttachFile(evt: ChangeEvent) { + const file = evt.target.files?.[0]; + if (!file) return; + setAttaching(true); + try { + if (imageUploadHandler) { + const url = await imageUploadHandler(file); + const safeName = file.name.replace(/[[\]]/g, "\\$&"); + const markdown = `![${safeName}](${url})`; + setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); + } else if (onAttachImage) { + await onAttachImage(file); } - }, - [onVote], - ); + } finally { + setAttaching(false); + if (attachInputRef.current) attachInputRef.current.value = ""; + } + } + + async function handleFeedbackVote( + commentId: string, + vote: FeedbackVoteValue, + options?: { allowSharing?: boolean; reason?: string }, + ) { + if (!onVote) return; + setVotingTargetId(commentId); + try { + await onVote(commentId, vote, options); + } finally { + setVotingTargetId(null); + } + } + + const canSubmit = !submitting && !!body.trim(); + + return ( +
+

Timeline ({timeline.length + queuedComments.length})

- const timelineSection = useMemo( - () => ( - ), - [ - timeline, agentMap, currentUserId, companyId, projectId, - onApproveApproval, onRejectApproval, pendingApprovalAction, - feedbackVoteByTargetId, feedbackDataSharingPreference, - onVote, handleFeedbackVote, votingTargetId, highlightCommentId, - feedbackTermsUrl, - ], - ); - - return ( -
-

Timeline ({timeline.length + queuedComments.length})

- - {timelineSection} {liveRunSlot} @@ -840,216 +903,92 @@ export const CommentThread = memo(function CommentThread({ {composerDisabledReason}
) : ( - +
+ +
+ {(imageUploadHandler || onAttachImage) && ( +
+ + +
+ )} + + {enableReassign && reassignOptions.length > 0 && ( + { + if (!option) return Assignee; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + renderOption={(option) => { + if (!option.id) return {option.label}; + const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; + const agent = agentId ? agentMap?.get(agentId) : null; + return ( + <> + {agent ? ( + + ) : null} + {option.label} + + ); + }} + /> + )} + +
+
)}
); -}); - -CommentThread.displayName = "CommentThread"; - -/* ---- Isolated Composer (body state lives here, not in CommentThread) ---- */ - -interface CommentComposerProps { - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise; - mentions: MentionOption[]; - imageUploadHandler?: (file: File) => Promise; - onAttachImage?: (file: File) => Promise; - draftKey?: string; - enableReassign: boolean; - reassignOptions: InlineEntityOption[]; - currentAssigneeValue: string; - suggestedAssigneeValue: string; - agentMap?: Map; } - -const CommentComposer = memo(function CommentComposer({ - onAdd, - mentions, - imageUploadHandler, - onAttachImage, - draftKey, - enableReassign, - reassignOptions, - currentAssigneeValue, - suggestedAssigneeValue, - agentMap, -}: CommentComposerProps) { - const [body, setBody] = useState(""); - const [reopen, setReopen] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [attaching, setAttaching] = useState(false); - const [reassignTarget, setReassignTarget] = useState(suggestedAssigneeValue); - const editorRef = useRef(null); - const attachInputRef = useRef(null); - const draftTimer = useRef | null>(null); - - useEffect(() => { - if (!draftKey) return; - setBody(loadDraft(draftKey)); - }, [draftKey]); - - useEffect(() => { - if (!draftKey) return; - if (draftTimer.current) clearTimeout(draftTimer.current); - draftTimer.current = setTimeout(() => { - saveDraft(draftKey, body); - }, DRAFT_DEBOUNCE_MS); - }, [body, draftKey]); - - useEffect(() => { - return () => { - if (draftTimer.current) clearTimeout(draftTimer.current); - }; - }, []); - - useEffect(() => { - setReassignTarget(suggestedAssigneeValue); - }, [suggestedAssigneeValue]); - - 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(submittedBody, reopen ? true : undefined, reassignment ?? undefined); - if (draftKey) clearDraft(draftKey); - setReopen(true); - setReassignTarget(suggestedAssigneeValue); - } catch { - setBody((current) => - restoreSubmittedCommentDraft({ - currentBody: current, - submittedBody, - }), - ); - } finally { - setSubmitting(false); - } - } - - async function handleAttachFile(evt: ChangeEvent) { - const file = evt.target.files?.[0]; - if (!file) return; - setAttaching(true); - try { - if (imageUploadHandler) { - const url = await imageUploadHandler(file); - const safeName = file.name.replace(/[[\]]/g, "\\$&"); - const markdown = `![${safeName}](${url})`; - setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); - } else if (onAttachImage) { - await onAttachImage(file); - } - } finally { - setAttaching(false); - if (attachInputRef.current) attachInputRef.current.value = ""; - } - } - - const canSubmit = !submitting && !!body.trim(); - - return ( -
- -
- {(imageUploadHandler || onAttachImage) && ( -
- - -
- )} - - {enableReassign && reassignOptions.length > 0 && ( - { - if (!option) return Assignee; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - renderOption={(option) => { - if (!option.id) return {option.label}; - const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null; - const agent = agentId ? agentMap?.get(agentId) : null; - return ( - <> - {agent ? ( - - ) : null} - {option.label} - - ); - }} - /> - )} - -
-
- ); -}); diff --git a/ui/src/components/GoalProperties.tsx b/ui/src/components/GoalProperties.tsx index fdc4da2ab4c..27700a0eb35 100644 --- a/ui/src/components/GoalProperties.tsx +++ b/ui/src/components/GoalProperties.tsx @@ -20,9 +20,9 @@ interface GoalPropertiesProps { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } diff --git a/ui/src/components/InstanceSidebar.tsx b/ui/src/components/InstanceSidebar.tsx index c9d8f2ebd78..6a1d0add150 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, Shield, SlidersHorizontal, import { NavLink } from "@/lib/router"; import { pluginsApi } from "@/api/plugins"; import { queryKeys } from "@/lib/queryKeys"; +import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { SidebarNavItem } from "./SidebarNavItem"; export function InstanceSidebar() { @@ -35,6 +36,7 @@ export function InstanceSidebar() { [ "rounded-md px-2 py-1.5 text-xs transition-colors", diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 884517b4404..00d3df61241 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -41,6 +41,7 @@ import { type IssueChatTranscriptEntry, type SegmentTiming, } from "../lib/issue-chat-messages"; +import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; @@ -423,7 +424,12 @@ function commentDateLabel(date: Date | string | undefined): string { function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { const { onImageClick } = useContext(IssueChatCtx); return ( - + {text} ); @@ -907,8 +913,6 @@ function IssueChatUserMessage() { ) : null}
) : null} - {pending ?
Sending...
: null} -
-
- - - - {message.createdAt ? commentDateLabel(message.createdAt) : ""} - - - - {message.createdAt ? formatDateTime(message.createdAt) : ""} - - - -
+ {pending ? ( +
Sending...
+ ) : ( +
+ + + + {message.createdAt ? commentDateLabel(message.createdAt) : ""} + + + + {message.createdAt ? formatDateTime(message.createdAt) : ""} + + + +
+ )}
@@ -1820,26 +1828,12 @@ export function IssueChatThread({ return [...deduped.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); }, [activeRun, liveRuns]); const transcriptRuns = useMemo(() => { - const combined = new Map(); - for (const run of displayLiveRuns) { - combined.set(run.id, { - id: run.id, - status: run.status, - adapterType: run.adapterType, - }); - } - for (const run of linkedRuns) { - if (combined.has(run.runId)) continue; - const adapterType = agentMap?.get(run.agentId)?.adapterType; - if (!adapterType) continue; - combined.set(run.runId, { - id: run.runId, - status: run.status, - adapterType, - }); - } - return [...combined.values()]; - }, [agentMap, displayLiveRuns, linkedRuns]); + return resolveIssueChatTranscriptRuns({ + linkedRuns, + liveRuns: displayLiveRuns, + activeRun, + }); + }, [activeRun, displayLiveRuns, linkedRuns]); const { transcriptByRun, hasOutputForRun } = useLiveRunTranscripts({ runs: enableLiveTranscriptPolling ? transcriptRuns : [], companyId, diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index a1489a7e02b..516a8c4506b 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -12,6 +12,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatAssigneeUserLabel } from "../lib/assignees"; import type { InboxIssueColumn } from "../lib/inbox"; import { cn } from "../lib/utils"; @@ -50,12 +51,12 @@ export function issueActivityText(issue: Issue): string { function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string { return columns .map((column) => { - if (column === "assignee") return "minmax(7.5rem, 9.5rem)"; - if (column === "project") return "minmax(6.5rem, 8.5rem)"; - if (column === "workspace") return "minmax(9rem, 12rem)"; - if (column === "parent") return "minmax(5rem, 7rem)"; - if (column === "labels") return "minmax(8rem, 10rem)"; - return "minmax(4rem, 5.5rem)"; + if (column === "assignee") return "minmax(6rem, 8rem)"; + if (column === "project") return "minmax(4.5rem, 7rem)"; + if (column === "workspace") return "minmax(6rem, 9rem)"; + if (column === "parent") return "minmax(3.5rem, 5.5rem)"; + if (column === "labels") return "minmax(3rem, 6rem)"; + return "minmax(3.5rem, 4.5rem)"; }) .join(" "); } @@ -66,24 +67,27 @@ export function IssueColumnPicker({ onToggleColumn, onResetColumns, title, + iconOnly = false, }: { availableColumns: InboxIssueColumn[]; visibleColumnSet: ReadonlySet; onToggleColumn: (column: InboxIssueColumn, enabled: boolean) => void; onResetColumns: () => void; title: string; + iconOnly?: boolean; }) { return ( @@ -189,23 +193,27 @@ export function InboxIssueTrailingColumns({ columns, projectName, projectColor, + workspaceId, workspaceName, assigneeName, currentUserId, parentIdentifier, parentTitle, assigneeContent, + onFilterWorkspace, }: { issue: Issue; columns: InboxIssueColumn[]; projectName: string | null; projectColor: string | null; + workspaceId?: string | null; workspaceName: string | null; assigneeName: string | null; currentUserId: string | null; parentIdentifier: string | null; parentTitle: string | null; assigneeContent?: ReactNode; + onFilterWorkspace?: (workspaceId: string) => void; }) { const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt); const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"; @@ -276,20 +284,22 @@ export function InboxIssueTrailingColumns({ if (column === "labels") { if ((issue.labels ?? []).length > 0) { return ( - + {(issue.labels ?? []).slice(0, 2).map((label) => ( {label.name} ))} {(issue.labels ?? []).length > 2 ? ( - + +{(issue.labels ?? []).length - 2} ) : null} @@ -307,7 +317,28 @@ export function InboxIssueTrailingColumns({ return ( - {workspaceName} + {workspaceId && onFilterWorkspace ? ( + + + + + + Filter by workspace + + + ) : ( + workspaceName + )} ); } diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index 117e5b13e05..9f04df9f433 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -119,6 +119,35 @@ vi.mock("@/components/ui/dropdown-menu", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +const localStorageEntries = new Map(); + +function ensureLocalStorageMock() { + if ( + typeof window.localStorage?.getItem === "function" + && typeof window.localStorage?.setItem === "function" + && typeof window.localStorage?.removeItem === "function" + && typeof window.localStorage?.clear === "function" + ) { + return; + } + + Object.defineProperty(window, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => localStorageEntries.get(key) ?? null, + setItem: (key: string, value: string) => { + localStorageEntries.set(key, value); + }, + removeItem: (key: string) => { + localStorageEntries.delete(key); + }, + clear: () => { + localStorageEntries.clear(); + }, + }, + }); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((res) => { @@ -221,6 +250,7 @@ describe("IssueDocumentsSection", () => { beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); + ensureLocalStorageMock(); window.localStorage.clear(); vi.clearAllMocks(); markdownEditorMockState.emitMountEmptyChange = false; @@ -311,6 +341,158 @@ describe("IssueDocumentsSection", () => { queryClient.clear(); }); + it("returns from a historical preview when the current revision only exists in derived state", async () => { + const currentDocument = createIssueDocument({ + body: "Current plan body", + latestRevisionId: "revision-4", + latestRevisionNumber: 4, + updatedAt: new Date("2026-03-31T12:05:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([currentDocument]); + queryClient.setQueryData( + queryKeys.issues.documentRevisions(issue.id, "plan"), + [ + createRevision({ + id: "revision-3", + revisionNumber: 3, + body: "Historical plan body", + createdAt: new Date("2026-03-31T11:00:00.000Z"), + }), + ], + ); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Current plan body"); + + const revisionButtons = Array.from(container.querySelectorAll("button")); + const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3")); + expect(historicalRevisionButton).toBeTruthy(); + + await act(async () => { + historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Viewing revision 3"); + expect(container.textContent).toContain("Historical plan body"); + + const currentRevisionButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("rev 4")); + expect(currentRevisionButton).toBeTruthy(); + + await act(async () => { + currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("Viewing revision 3"); + expect(container.textContent).toContain("Current plan body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + + it("returns from a historical preview when fetched history is newer than the document summary", async () => { + const staleDocument = createIssueDocument({ + body: "Original plan body", + latestRevisionId: "revision-2", + latestRevisionNumber: 2, + updatedAt: new Date("2026-03-31T12:00:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([staleDocument]); + queryClient.setQueryData( + queryKeys.issues.documentRevisions(issue.id, "plan"), + [ + createRevision({ + id: "revision-3", + revisionNumber: 3, + body: "Current plan body", + createdAt: new Date("2026-03-31T12:05:00.000Z"), + }), + createRevision({ + id: "revision-2", + revisionNumber: 2, + body: "Original plan body", + createdAt: new Date("2026-03-31T12:00:00.000Z"), + }), + ], + ); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + expect(container.textContent).toContain("Current plan body"); + + const revisionButtons = Array.from(container.querySelectorAll("button")); + const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 2")); + expect(historicalRevisionButton).toBeTruthy(); + + await act(async () => { + historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Viewing revision 2"); + expect(container.textContent).toContain("Original plan body"); + + const currentRevisionButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("rev 3")); + expect(currentRevisionButton).toBeTruthy(); + + await act(async () => { + currentRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).not.toContain("Viewing revision 2"); + expect(container.textContent).toContain("Current plan body"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + it("ignores mount-time editor change noise before a document is actively being edited", async () => { markdownEditorMockState.emitMountEmptyChange = true; @@ -351,4 +533,51 @@ describe("IssueDocumentsSection", () => { }); queryClient.clear(); }); + + it("wraps the documents header actions so mobile layouts do not overflow", async () => { + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([createIssueDocument()]); + + await act(async () => { + root.render( + + + + + + )} + /> + , + ); + }); + + await flush(); + await flush(); + + const heading = container.querySelector("h3"); + expect(heading).toBeTruthy(); + expect(heading?.parentElement?.className).toContain("flex-wrap"); + expect(heading?.nextElementSibling?.className).toContain("flex-wrap"); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); }); diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 26db7266093..0acbca9a6a7 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -12,6 +12,7 @@ import { useLocation } from "@/lib/router"; import { ApiError } from "../api/client"; import { issuesApi } from "../api/issues"; import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator"; +import { deriveDocumentRevisionState } from "../lib/document-revisions"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { MarkdownBody } from "./MarkdownBody"; @@ -69,7 +70,7 @@ function saveFoldedDocumentKeys(issueId: string, keys: string[]) { } function renderBody(body: string, className?: string) { - return {body}; + return {body}; } function isPlanKey(key: string) { @@ -536,10 +537,10 @@ export function IssueDocumentsSection({ }, []); const previewRevision = useCallback((doc: IssueDocument, revisionId: string) => { - const revisions = getDocumentRevisions(doc.key); - const selectedRevision = revisions.find((revision) => revision.id === revisionId); + const revisionState = deriveDocumentRevisionState(doc, getDocumentRevisions(doc.key)); + const selectedRevision = revisionState.revisions.find((revision) => revision.id === revisionId); if (!selectedRevision) return; - if (selectedRevision.id === doc.latestRevisionId) { + if (selectedRevision.id === revisionState.currentRevision.id) { returnToLatestRevision(doc.key); return; } @@ -683,7 +684,7 @@ export function IssueDocumentsSection({ return (
{isEmpty && !draft?.isNew ? ( -
+
{extraActions}
) : ( -
-

Documents

-
+
+

Documents

+
{extraActions} + + +
+
+ Filters + {activeFilterCount > 0 ? ( + + ) : null} +
+ +
+ Quick filters +
+ {issueQuickFilterPresets.map((preset) => { + const isActive = issueFilterArraysEqual(state.statuses, preset.statuses); + return ( + + ); + })} +
+
+ +
+ +
+
+ Status +
+ {issueStatusOrder.map((status) => ( + + ))} +
+
+ +
+
+ Priority +
+ {issuePriorityOrder.map((priority) => ( + + ))} +
+
+ +
+ Assignee +
+ + {currentUserId ? ( + + ) : null} + {(agents ?? []).map((agent) => ( + + ))} +
+
+ + {labels && labels.length > 0 ? ( +
+ Labels +
+ {labels.map((label) => ( + + ))} +
+
+ ) : null} + + {projects && projects.length > 0 ? ( +
+ Project +
+ {projects.map((project) => ( + + ))} +
+
+ ) : null} + + {workspaces && workspaces.length > 0 ? ( +
+ Workspace +
+ {workspaces.map((workspace) => ( + + ))} +
+
+ ) : null} + + {enableRoutineVisibilityFilter ? ( +
+ Visibility + +
+ ) : null} +
+
+
+ + + ); +} diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx new file mode 100644 index 00000000000..4bb0048f98d --- /dev/null +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; +import { useMemo, useState } from "react"; +import * as RouterDom from "react-router-dom"; +import type { Issue } from "@paperclipai/shared"; +import { useQuery } from "@tanstack/react-query"; +import { issuesApi } from "@/api/issues"; +import { queryKeys } from "@/lib/queryKeys"; +import { timeAgo } from "@/lib/timeAgo"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "@/lib/issueDetailBreadcrumb"; +import { cn } from "@/lib/utils"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { StatusIcon } from "@/components/StatusIcon"; + +function summarizeIssueDescription(description: string | null | undefined) { + if (!description) return null; + const summary = description + .replace(/!\[[^\]]*]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[#>*_`~-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + + if (!summary) return null; + return summary.length > 180 ? `${summary.slice(0, 177).trimEnd()}...` : summary; +} + +export function IssueQuicklookCard({ + issue, + linkTo, + linkState, + compact = false, +}: { + issue: Issue; + linkTo: RouterDom.To; + linkState?: unknown; + compact?: boolean; +}) { + const description = useMemo(() => summarizeIssueDescription(issue.description), [issue.description]); + + return ( +
+
+ + + {issue.title} + +
+
+ {issue.identifier ?? issue.id.slice(0, 8)} + · + {issue.status.replace(/_/g, " ")} + · + {timeAgo(new Date(issue.updatedAt))} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ ); +} + +export const IssueLinkQuicklook = React.forwardRef< + HTMLAnchorElement, + React.ComponentProps & { issuePathId: string } +>(function IssueLinkQuicklookImpl( + { + issuePathId, + to, + children, + className, + onClick, + ...props + }, + ref, +) { + const [open, setOpen] = useState(false); + const { data, isLoading } = useQuery({ + queryKey: queryKeys.issues.detail(issuePathId), + queryFn: () => issuesApi.get(issuePathId), + enabled: open, + staleTime: 60_000, + }); + + const detailPath = createIssueDetailPath(issuePathId); + + return ( + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + { + setOpen(false); + onClick?.(event); + }} + {...props} + > + {children} + + + setOpen(true)} + onMouseLeave={() => setOpen(false)} + onOpenAutoFocus={(event) => event.preventDefault()} + > + {data ? ( + + ) : ( +
+
+
+
+ {!isLoading ? ( +

Unable to load issue preview.

+ ) : null} +
+ )} + + + ); +}); diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index a1fe7e1c341..3c92683a7b0 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -18,6 +18,7 @@ const mockProjectsApi = vi.hoisted(() => ({ })); const mockIssuesApi = vi.hoisted(() => ({ + list: vi.fn(), listLabels: vi.fn(), })); @@ -193,6 +194,7 @@ describe("IssueProperties", () => { document.body.appendChild(container); mockAgentsApi.list.mockResolvedValue([]); mockProjectsApi.list.mockResolvedValue([]); + mockIssuesApi.list.mockResolvedValue([]); mockIssuesApi.listLabels.mockResolvedValue([]); mockAuthApi.getSession.mockResolvedValue({ user: { id: "user-1" } }); }); @@ -227,6 +229,119 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); + it("shows an add-label button when labels already exist and opens the picker", async () => { + const root = renderProperties(container, { + issue: createIssue({ + labels: [{ id: "label-1", companyId: "company-1", name: "Bug", color: "#ef4444", createdAt: new Date("2026-04-06T12:00:00.000Z"), updatedAt: new Date("2026-04-06T12:00:00.000Z") }], + labelIds: ["label-1"], + }), + childIssues: [], + onUpdate: vi.fn(), + inline: true, + }); + await flush(); + + const addLabelButton = container.querySelector('button[aria-label="Add label"]'); + expect(addLabelButton).not.toBeNull(); + expect(container.querySelector('input[placeholder="Search labels..."]')).toBeNull(); + + await act(async () => { + addLabelButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(container.querySelector('input[placeholder="Search labels..."]')).not.toBeNull(); + expect(container.querySelector('button[title="Delete Bug"]')).toBeNull(); + + act(() => root.unmount()); + }); + + it("allows setting and clearing a parent issue from the properties pane", async () => { + const onUpdate = vi.fn(); + mockIssuesApi.list.mockResolvedValue([ + createIssue({ id: "issue-2", identifier: "PAP-2", title: "Candidate parent", status: "in_progress" }), + ]); + + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const parentTrigger = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("No parent")); + expect(parentTrigger).not.toBeUndefined(); + + await act(async () => { + parentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const candidateButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); + expect(candidateButton).not.toBeUndefined(); + + await act(async () => { + candidateButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ parentId: "issue-2" }); + + onUpdate.mockClear(); + const rerenderedIssue = createIssue({ + parentId: "issue-2", + ancestors: [ + { + id: "issue-2", + identifier: "PAP-2", + title: "Candidate parent", + description: null, + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: null, + projectId: null, + goalId: null, + project: null, + goal: null, + }, + ], + }); + + act(() => root.unmount()); + + const rerenderedRoot = renderProperties(container, { + issue: rerenderedIssue, + childIssues: [], + onUpdate, + inline: true, + }); + await flush(); + + const selectedParentTrigger = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("PAP-2 Candidate parent")); + expect(selectedParentTrigger).not.toBeUndefined(); + + await act(async () => { + selectedParentTrigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + const clearParentButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("No parent")); + expect(clearParentButton).not.toBeUndefined(); + + await act(async () => { + clearParentButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onUpdate).toHaveBeenCalledWith({ parentId: null }); + + act(() => rerenderedRoot.unmount()); + }); + it("shows a run review action after reviewers are configured and starts execution explicitly when clicked", async () => { const onUpdate = vi.fn(); const root = renderProperties(container, { diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index c6d33ff2dff..ed7a5c31fd9 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -20,7 +20,7 @@ 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, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Copy, Check } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { @@ -82,9 +82,9 @@ interface IssuePropertiesProps { function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return ( -
- {label} -
{children}
+
+ {label} +
{children}
); } @@ -114,7 +114,7 @@ function PropertyPicker({ children: React.ReactNode; }) { const btnCn = cn( - "inline-flex items-center gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors", + "inline-flex items-start gap-1.5 cursor-pointer hover:bg-accent/50 rounded px-1 -mx-1 py-0.5 transition-colors min-w-0 max-w-full text-left", triggerClassName, ); @@ -167,6 +167,8 @@ export function IssueProperties({ const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); const [blockedBySearch, setBlockedBySearch] = useState(""); + const [parentOpen, setParentOpen] = useState(false); + const [parentSearch, setParentSearch] = useState(""); const [reviewersOpen, setReviewersOpen] = useState(false); const [reviewerSearch, setReviewerSearch] = useState(""); const [approversOpen, setApproversOpen] = useState(false); @@ -212,7 +214,7 @@ export function IssueProperties({ const { data: allIssues } = useQuery({ queryKey: queryKeys.issues.list(companyId!), queryFn: () => issuesApi.list(companyId!), - enabled: !!companyId && blockedByOpen, + enabled: !!companyId && (blockedByOpen || parentOpen), }); const createLabel = useMutation({ @@ -224,15 +226,6 @@ export function IssueProperties({ }, }); - const deleteLabel = useMutation({ - mutationFn: (labelId: string) => issuesApi.deleteLabel(labelId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.issues.labels(companyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issue.id) }); - }, - }); - const toggleLabel = (labelId: string) => { const ids = issue.labelIds ?? []; const next = ids.includes(labelId) @@ -304,10 +297,10 @@ export function IssueProperties({ return value; }; const reviewerTrigger = reviewerValues.length > 0 - ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {reviewerValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const approverTrigger = approverValues.length > 0 - ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} + ? {approverValues.map((value) => executionParticipantLabel(value)).join(", ")} : None; const nextRunnableExecutionStage = (() => { if (issue.executionState?.status === "changes_requested" && issue.executionState.currentStageType) { @@ -369,6 +362,17 @@ export function IssueProperties({ No labels ); + const labelsExtra = (issue.labelIds ?? []).length > 0 ? ( + + ) : undefined; const labelsContent = ( <> @@ -388,26 +392,17 @@ export function IssueProperties({ .map((label) => { const selected = (issue.labelIds ?? []).includes(label.id); return ( -
- - -
+ ); })}
@@ -609,7 +604,7 @@ export function IssueProperties({ className="shrink-0 h-3 w-3 rounded-sm" style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }} /> - {projectName(issue.projectId)} + {projectName(issue.projectId)} ) : ( <> @@ -685,6 +680,100 @@ export function IssueProperties({ ); const blockedByIds = issue.blockedBy?.map((relation) => relation.id) ?? []; + const descendantIssueIds = useMemo(() => { + if (!allIssues?.length) return new Set(); + const childrenByParentId = new Map(); + for (const candidate of allIssues) { + if (!candidate.parentId) continue; + const children = childrenByParentId.get(candidate.parentId) ?? []; + children.push(candidate.id); + childrenByParentId.set(candidate.parentId, children); + } + + const descendants = new Set(); + const stack = [...(childrenByParentId.get(issue.id) ?? [])]; + while (stack.length > 0) { + const candidateId = stack.pop(); + if (!candidateId || descendants.has(candidateId)) continue; + descendants.add(candidateId); + stack.push(...(childrenByParentId.get(candidateId) ?? [])); + } + return descendants; + }, [allIssues, issue.id]); + const currentParentIssue = useMemo(() => { + if (!issue.parentId) return null; + return allIssues?.find((candidate) => candidate.id === issue.parentId) ?? null; + }, [allIssues, issue.parentId]); + const parentTrigger = issue.parentId ? ( + + {issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier + ? `${issue.ancestors?.[0]?.identifier ?? currentParentIssue?.identifier} ` + : ""} + {issue.ancestors?.[0]?.title ?? currentParentIssue?.title ?? issue.parentId.slice(0, 8)} + + ) : ( + No parent + ); + const parentOptions = (allIssues ?? []) + .filter((candidate) => candidate.id !== issue.id) + .filter((candidate) => !descendantIssueIds.has(candidate.id)) + .filter((candidate) => { + if (!parentSearch.trim()) return true; + const query = parentSearch.toLowerCase(); + return ( + (candidate.identifier ?? "").toLowerCase().includes(query) || + candidate.title.toLowerCase().includes(query) + ); + }) + .sort((a, b) => { + const aLabel = `${a.identifier ?? ""} ${a.title}`.trim(); + const bLabel = `${b.identifier ?? ""} ${b.title}`.trim(); + return aLabel.localeCompare(bLabel); + }); + const parentContent = ( + <> + setParentSearch(e.target.value)} + autoFocus={!inline} + /> +
+ + {parentOptions.map((candidate) => ( + + ))} +
+ + ); const blockedByTrigger = blockedByIds.length > 0 ? (
{(issue.blockedBy ?? []).slice(0, 2).map((relation) => ( @@ -793,6 +882,7 @@ export function IssueProperties({ triggerContent={labelsTrigger} triggerClassName="min-w-0 max-w-full" popoverClassName="w-64" + extra={labelsExtra} > {labelsContent} @@ -838,6 +928,30 @@ export function IssueProperties({ {projectContent} + { + setParentOpen(open); + if (!open) setParentSearch(""); + }} + triggerContent={parentTrigger} + triggerClassName="min-w-0 max-w-full" + popoverClassName="w-72" + extra={issue.parentId ? ( + e.stopPropagation()} + > + + + ) : undefined} + > + {parentContent} + + )} - {issue.parentId && ( - - - {issue.ancestors?.[0]?.title ?? issue.parentId.slice(0, 8)} - - - )} {issue.requestDepth > 0 && ( {issue.requestDepth} diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 4ba0cee2e6a..2b52a042b8c 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -7,8 +7,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssueRow } from "./IssueRow"; vi.mock("@/lib/router", () => ({ - Link: ({ children, className, ...props }: React.ComponentProps<"a">) => ( - {children} + Link: ({ children, className, disableIssueQuicklook: _disableIssueQuicklook, ...props }: React.ComponentProps<"a"> & { disableIssueQuicklook?: boolean }) => ( + + {children} + ), })); @@ -135,6 +141,22 @@ describe("IssueRow", () => { }); }); + it("opts issue quicklook out for dense inbox rows", () => { + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + expect(link).not.toBeNull(); + expect(link?.getAttribute("data-disable-issue-quicklook")).toBe("true"); + + act(() => { + root.unmount(); + }); + }); + it("renders titleSuffix inline after the issue title", () => { const root = createRoot(container); const issue = createIssue({ title: "Parent task" }); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 09df3f038b5..2ba4e92ea10 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -2,7 +2,11 @@ import type { ReactNode } from "react"; import type { Issue } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { X } from "lucide-react"; -import { createIssueDetailPath, rememberIssueDetailLocationState } from "../lib/issueDetailBreadcrumb"; +import { + createIssueDetailPath, + rememberIssueDetailLocationState, + withIssueDetailHeaderSeed, +} from "../lib/issueDetailBreadcrumb"; import { cn } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -48,13 +52,15 @@ export function IssueRow({ const showUnreadSlot = unreadState !== null; const showUnreadDot = unreadState === "visible" || unreadState === "fading"; const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined; + const detailState = withIssueDetailHeaderSeed(issueLinkState, issue); return ( rememberIssueDetailLocationState(issuePathId, issueLinkState)} + onClickCapture={() => rememberIssueDetailLocationState(issuePathId, detailState)} className={cn( "group flex items-start gap-2 border-b border-border py-2.5 pl-2 pr-3 text-sm no-underline text-inherit transition-colors last:border-b-0 sm:items-center sm:py-2 sm:pl-1", selected ? "hover:bg-transparent" : "hover:bg-accent/50", diff --git a/ui/src/components/IssuesList.test.tsx b/ui/src/components/IssuesList.test.tsx index 16e6faf585c..e87fa41c8ba 100644 --- a/ui/src/components/IssuesList.test.tsx +++ b/ui/src/components/IssuesList.test.tsx @@ -7,6 +7,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { IssuesList } from "./IssuesList"; +import { TooltipProvider } from "@/components/ui/tooltip"; const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1", @@ -161,7 +162,9 @@ function renderWithQueryClient(node: ReactNode, container: HTMLDivElement) { act(() => { root.render( - {node} + + {node} + , ); }); @@ -297,7 +300,10 @@ describe("IssuesList", () => { ); await waitForAssertion(() => { - expect(container.textContent).toContain("Columns"); + const columnsButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.getAttribute("title") === "Columns", + ); + expect(columnsButton).not.toBeUndefined(); expect(container.textContent).toContain("PAP-9"); expect(container.textContent).toContain("Agent One"); expect(container.textContent).not.toContain("Updated"); @@ -307,4 +313,209 @@ describe("IssuesList", () => { root.unmount(); }); }); + + it("filters the list to a single workspace when a workspace name is clicked", async () => { + localStorage.setItem("paperclip:inbox:issue-columns", JSON.stringify(["id", "workspace"])); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true }); + mockExecutionWorkspacesApi.list.mockResolvedValue([ + { + id: "workspace-alpha", + name: "Alpha", + mode: "isolated_workspace", + status: "active", + projectWorkspaceId: null, + }, + { + id: "workspace-beta", + name: "Beta", + mode: "isolated_workspace", + status: "active", + projectWorkspaceId: null, + }, + ]); + + const alphaIssue = createIssue({ + id: "issue-alpha", + identifier: "PAP-20", + title: "Alpha issue", + executionWorkspaceId: "workspace-alpha", + }); + const betaIssue = createIssue({ + id: "issue-beta", + identifier: "PAP-21", + title: "Beta issue", + executionWorkspaceId: "workspace-beta", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).toContain("Beta issue"); + const workspaceButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Alpha", + ); + expect(workspaceButton).not.toBeUndefined(); + }); + + await act(async () => { + const workspaceButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Alpha", + ); + workspaceButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Alpha issue"); + expect(container.textContent).not.toContain("Beta issue"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("hides routine-backed issues by default and reveals them when the routine filter is enabled", async () => { + const manualIssue = createIssue({ + id: "issue-manual", + identifier: "PAP-10", + title: "Manual issue", + originKind: "manual", + }); + const routineIssue = createIssue({ + id: "issue-routine", + identifier: "PAP-11", + title: "Routine issue", + originKind: "routine_execution", + }); + + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Manual issue"); + expect(container.textContent).not.toContain("Routine issue"); + }); + + await act(async () => { + const filterButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.getAttribute("title") === "Filter", + ); + filterButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + const toggle = Array.from(document.body.querySelectorAll("label")).find( + (label) => label.textContent?.includes("Show routine runs"), + ); + expect(toggle).not.toBeUndefined(); + }); + + await act(async () => { + const toggle = Array.from(document.body.querySelectorAll("label")).find( + (label) => label.textContent?.includes("Show routine runs"), + ); + toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + + await waitForAssertion(() => { + expect(container.textContent).toContain("Routine issue"); + }); + + act(() => { + root.unmount(); + }); + }); + + it("blurs the search input on Enter without clearing the query", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + input?.focus(); + expect(document.activeElement).toBe(input); + }); + + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement; + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + })); + }); + + expect(document.activeElement).not.toBe(input); + expect(input.value).toBe("bug"); + + act(() => { + root.unmount(); + }); + }); + + it("blurs the search input on Escape once the field is empty", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement | null; + expect(input).not.toBeNull(); + input?.focus(); + expect(document.activeElement).toBe(input); + }); + + const input = container.querySelector('input[aria-label="Search issues"]') as HTMLInputElement; + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + })); + }); + + expect(document.activeElement).not.toBe(input); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 198a1a1619e..9dde9e08807 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -7,8 +7,22 @@ import { issuesApi } from "../api/issues"; import { authApi } from "../api/auth"; import { instanceSettingsApi } from "../api/instanceSettings"; import { queryKeys } from "../lib/queryKeys"; +import { + shouldBlurPageSearchOnEnter, + shouldBlurPageSearchOnEscape, +} from "../lib/keyboardShortcuts"; import { formatAssigneeUserLabel } from "../lib/assignees"; import { groupBy } from "../lib/groupBy"; +import { + applyIssueFilters, + countActiveIssueFilters, + defaultIssueFilterState, + issueFilterLabel, + issuePriorityOrder, + resolveIssueFilterWorkspaceId, + issueStatusOrder, + type IssueFilterState, +} from "../lib/issue-filters"; import { DEFAULT_INBOX_ISSUE_COLUMNS, getAvailableInboxIssueColumns, @@ -27,39 +41,24 @@ import { issueTrailingColumns, } from "./IssueColumns"; import { StatusIcon } from "./StatusIcon"; -import { PriorityIcon } from "./PriorityIcon"; import { EmptyState } from "./EmptyState"; import { Identity } from "./Identity"; +import { IssueFiltersPopover } from "./IssueFiltersPopover"; import { IssueRow } from "./IssueRow"; import { PageSkeleton } from "./PageSkeleton"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; -import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3, User, Search } from "lucide-react"; +import { CircleDot, Plus, ArrowUpDown, Layers, Check, ChevronRight, List, Columns3, User, Search } from "lucide-react"; import { KanbanBoard } from "./KanbanBoard"; import { buildIssueTree, countDescendants } from "../lib/issue-tree"; import type { Issue, Project } from "@paperclipai/shared"; - -/* ── Helpers ── */ - -const statusOrder = ["in_progress", "todo", "backlog", "in_review", "blocked", "done", "cancelled"]; -const priorityOrder = ["critical", "high", "medium", "low"]; const ISSUE_SEARCH_DEBOUNCE_MS = 150; -function statusLabel(status: string): string { - return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - /* ── View state ── */ -export type IssueViewState = { - statuses: string[]; - priorities: string[]; - assignees: string[]; - labels: string[]; - projects: string[]; +export type IssueViewState = IssueFilterState & { sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "workspace" | "parent" | "none"; @@ -69,11 +68,7 @@ export type IssueViewState = { }; const defaultViewState: IssueViewState = { - statuses: [], - priorities: [], - assignees: [], - labels: [], - projects: [], + ...defaultIssueFilterState, sortField: "updated", sortDir: "desc", groupBy: "none", @@ -81,13 +76,6 @@ const defaultViewState: IssueViewState = { collapsedGroups: [], collapsedParents: [], }; - -const quickFilterPresets = [ - { label: "All", statuses: [] as string[] }, - { label: "Active", statuses: ["todo", "in_progress", "in_review", "blocked"] }, - { label: "Backlog", statuses: ["backlog"] }, - { label: "Done", statuses: ["done", "cancelled"] }, -]; function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); @@ -100,45 +88,15 @@ function saveViewState(key: string, state: IssueViewState) { localStorage.setItem(key, JSON.stringify(state)); } -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - const sa = [...a].sort(); - const sb = [...b].sort(); - return sa.every((v, i) => v === sb[i]); -} - -function toggleInArray(arr: string[], value: string): string[] { - return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; -} - -function applyFilters(issues: Issue[], state: IssueViewState, currentUserId?: string | null): Issue[] { - let result = issues; - if (state.statuses.length > 0) result = result.filter((i) => state.statuses.includes(i.status)); - if (state.priorities.length > 0) result = result.filter((i) => state.priorities.includes(i.priority)); - if (state.assignees.length > 0) { - result = result.filter((issue) => { - for (const assignee of state.assignees) { - if (assignee === "__unassigned" && !issue.assigneeAgentId && !issue.assigneeUserId) return true; - if (assignee === "__me" && currentUserId && issue.assigneeUserId === currentUserId) return true; - if (issue.assigneeAgentId === assignee) return true; - } - return false; - }); - } - if (state.labels.length > 0) result = result.filter((i) => (i.labelIds ?? []).some((id) => state.labels.includes(id))); - if (state.projects.length > 0) result = result.filter((i) => i.projectId != null && state.projects.includes(i.projectId)); - return result; -} - function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { const sorted = [...issues]; const dir = state.sortDir === "asc" ? 1 : -1; sorted.sort((a, b) => { switch (state.sortField) { case "status": - return dir * (statusOrder.indexOf(a.status) - statusOrder.indexOf(b.status)); + return dir * (issueStatusOrder.indexOf(a.status) - issueStatusOrder.indexOf(b.status)); case "priority": - return dir * (priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); + return dir * (issuePriorityOrder.indexOf(a.priority) - issuePriorityOrder.indexOf(b.priority)); case "title": return dir * a.title.localeCompare(b.title); case "created": @@ -152,16 +110,6 @@ function sortIssues(issues: Issue[], state: IssueViewState): Issue[] { return sorted; } -function countActiveFilters(state: IssueViewState): number { - let count = 0; - if (state.statuses.length > 0) count++; - if (state.priorities.length > 0) count++; - if (state.assignees.length > 0) count++; - if (state.labels.length > 0) count++; - if (state.projects.length > 0) count++; - return count; -} - /* ── Component ── */ interface Agent { @@ -186,6 +134,7 @@ interface IssuesListProps { searchFilters?: { participantAgentId?: string; }; + enableRoutineVisibilityFilter?: boolean; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -226,9 +175,27 @@ function IssueSearchInput({ onChange={(e) => { setDraftValue(e.target.value); }} + onKeyDown={(e) => { + if (shouldBlurPageSearchOnEnter({ + key: e.key, + isComposing: e.nativeEvent.isComposing, + })) { + e.currentTarget.blur(); + return; + } + + if (shouldBlurPageSearchOnEscape({ + key: e.key, + isComposing: e.nativeEvent.isComposing, + currentValue: e.currentTarget.value, + })) { + e.currentTarget.blur(); + } + }} placeholder="Search issues..." className="pl-7 text-xs sm:text-sm" aria-label="Search issues" + data-page-search-target="true" />
); @@ -247,6 +214,7 @@ export function IssuesList({ initialAssignees, initialSearch, searchFilters, + enableRoutineVisibilityFilter = false, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -319,8 +287,15 @@ export function IssuesList({ queryKey: [ ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), searchFilters ?? {}, + enableRoutineVisibilityFilter ? "with-routine-executions" : "without-routine-executions", ], - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), + queryFn: () => + issuesApi.list(selectedCompanyId!, { + q: normalizedIssueSearch, + projectId, + ...searchFilters, + ...(enableRoutineVisibilityFilter ? { includeRoutineExecutions: true } : {}), + }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, placeholderData: (previousData) => previousData, }); @@ -394,6 +369,16 @@ export function IssuesList({ return map; }, [executionWorkspaceById, projectWorkspaceById]); + const workspaceOptions = useMemo(() => { + const options = new Map(); + for (const [workspaceId, workspaceName] of workspaceNameMap) { + options.set(workspaceId, workspaceName); + } + return [...options.entries()] + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([id, name]) => ({ id, name })); + }, [workspaceNameMap]); + const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]); const availableIssueColumns = useMemo( () => getAvailableInboxIssueColumns(isolatedWorkspacesEnabled), @@ -423,9 +408,9 @@ export function IssuesList({ const filtered = useMemo(() => { const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; - const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); + const filteredByControls = applyIssueFilters(sourceIssues, viewState, currentUserId, enableRoutineVisibilityFilter); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); + }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId, enableRoutineVisibilityFilter]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -433,7 +418,7 @@ export function IssuesList({ enabled: !!selectedCompanyId, }); - const activeFilterCount = countActiveFilters(viewState); + const activeFilterCount = countActiveIssueFilters(viewState, enableRoutineVisibilityFilter); const groupedContent = useMemo(() => { if (viewState.groupBy === "none") { @@ -441,18 +426,18 @@ export function IssuesList({ } if (viewState.groupBy === "status") { const groups = groupBy(filtered, (i) => i.status); - return statusOrder + return issueStatusOrder .filter((s) => groups[s]?.length) - .map((s) => ({ key: s, label: statusLabel(s), items: groups[s]! })); + .map((s) => ({ key: s, label: issueFilterLabel(s), items: groups[s]! })); } if (viewState.groupBy === "priority") { const groups = groupBy(filtered, (i) => i.priority); - return priorityOrder + return issuePriorityOrder .filter((p) => groups[p]?.length) - .map((p) => ({ key: p, label: statusLabel(p), items: groups[p]! })); + .map((p) => ({ key: p, label: issueFilterLabel(p), items: groups[p]! })); } if (viewState.groupBy === "workspace") { - const groups = groupBy(filtered, (i) => i.projectWorkspaceId ?? "__no_workspace"); + const groups = groupBy(filtered, (issue) => resolveIssueFilterWorkspaceId(issue) ?? "__no_workspace"); return Object.keys(groups) .sort((a, b) => { // Groups with items first, "no workspace" last @@ -515,6 +500,10 @@ export function IssuesList({ return defaults; }, [projectId, viewState.groupBy]); + const filterToWorkspace = useCallback((workspaceId: string) => { + updateView({ workspaces: [workspaceId] }); + }, [updateView]); + const setIssueColumns = useCallback((next: InboxIssueColumn[]) => { const normalized = normalizeInboxIssueColumns(next); setVisibleIssueColumns(normalized); @@ -579,185 +568,28 @@ export function IssuesList({ onToggleColumn={toggleIssueColumn} onResetColumns={() => setIssueColumns(DEFAULT_INBOX_ISSUE_COLUMNS)} title="Choose which issue columns stay visible" + iconOnly /> - {/* Filter */} - - - - - -
-
- Filters - {activeFilterCount > 0 && ( - - )} -
- - {/* Quick filters */} -
- Quick filters -
- {quickFilterPresets.map((preset) => { - const isActive = arraysEqual(viewState.statuses, preset.statuses); - return ( - - ); - })} -
-
- -
- - {/* Multi-column filter sections */} -
- {/* Status */} -
- Status -
- {statusOrder.map((s) => ( - - ))} -
-
- - {/* Priority + Assignee stacked in right column */} -
- {/* Priority */} -
- Priority -
- {priorityOrder.map((p) => ( - - ))} -
-
- - {/* Assignee */} -
- Assignee -
- - {currentUserId && ( - - )} - {(agents ?? []).map((agent) => ( - - ))} -
-
- - {labels && labels.length > 0 && ( -
- Labels -
- {labels.map((label) => ( - - ))} -
-
- )} - - {projects && projects.length > 0 && ( -
- Project -
- {projects.map((project) => ( - - ))} -
-
- )} -
-
-
- - + ({ id: project.id, name: project.name }))} + labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} + currentUserId={currentUserId} + enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} + iconOnly + workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} + /> {/* Sort (list view only) */} {viewState.viewMode === "list" && ( - @@ -799,9 +631,8 @@ export function IssuesList({ {viewState.viewMode === "list" && ( - @@ -958,11 +789,13 @@ export function IssuesList({ columns={visibleTrailingIssueColumns} projectName={issueProject?.name ?? null} projectColor={issueProject?.color ?? null} + workspaceId={resolveIssueFilterWorkspaceId(issue)} workspaceName={resolveIssueWorkspaceName(issue, { executionWorkspaceById, projectWorkspaceById, defaultProjectWorkspaceIdByProjectId, })} + onFilterWorkspace={filterToWorkspace} assigneeName={agentName(issue.assigneeAgentId)} currentUserId={currentUserId} parentIdentifier={parentIssue?.identifier ?? null} diff --git a/ui/src/components/IssuesQuicklook.tsx b/ui/src/components/IssuesQuicklook.tsx index c4e02e49b5a..f8a12ce67d7 100644 --- a/ui/src/components/IssuesQuicklook.tsx +++ b/ui/src/components/IssuesQuicklook.tsx @@ -1,10 +1,8 @@ import { useState } from "react"; import type { Issue } from "@paperclipai/shared"; -import { Link } from "@/lib/router"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { StatusIcon } from "./StatusIcon"; -import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb"; -import { timeAgo } from "../lib/timeAgo"; +import { createIssueDetailPath, withIssueDetailHeaderSeed } from "../lib/issueDetailBreadcrumb"; +import { IssueQuicklookCard } from "./IssueLinkQuicklook"; interface IssuesQuicklookProps { issue: Issue; @@ -24,31 +22,18 @@ export function IssuesQuicklook({ issue, children }: IssuesQuicklookProps) { {children} setOpen(true)} onMouseLeave={() => setOpen(false)} onOpenAutoFocus={(e) => e.preventDefault()} > -
-
- - - {issue.title} - -
-
- {issue.identifier ?? issue.id.slice(0, 8)} - · - {issue.status.replace(/_/g, " ")} - · - {timeAgo(new Date(issue.updatedAt))} -
-
+
); diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx index 8b2bad2cec0..3e1ef1fb6bf 100644 --- a/ui/src/components/KanbanBoard.tsx +++ b/ui/src/components/KanbanBoard.tsx @@ -148,6 +148,7 @@ function KanbanCard({ > { // Prevent navigation during drag diff --git a/ui/src/components/KeyboardShortcutsCheatsheet.tsx b/ui/src/components/KeyboardShortcutsCheatsheet.tsx index 937292adccd..45d6858ded3 100644 --- a/ui/src/components/KeyboardShortcutsCheatsheet.tsx +++ b/ui/src/components/KeyboardShortcutsCheatsheet.tsx @@ -34,6 +34,7 @@ const sections: ShortcutSection[] = [ { title: "Global", shortcuts: [ + { keys: ["/"], label: "Search current page or quick search" }, { keys: ["c"], label: "New issue" }, { keys: ["["], label: "Toggle sidebar" }, { keys: ["]"], label: "Toggle panel" }, diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index cdb91dd2c35..9ea147a85b4 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -21,8 +21,9 @@ let currentPathname = "/PAP/dashboard"; vi.mock("@/lib/router", () => ({ Outlet: () =>
Outlet content
, - useLocation: () => ({ pathname: currentPathname, search: "", hash: "" }), + useLocation: () => ({ pathname: currentPathname, search: "", hash: "", state: null }), useNavigate: () => mockNavigate, + useNavigationType: () => "PUSH", useParams: () => ({ companyPrefix: "PAP" }), })); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 2d9740484aa..b0281df7f0b 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; @@ -32,6 +32,10 @@ import { DEFAULT_INSTANCE_SETTINGS_PATH, normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; +import { + resetNavigationScroll, + shouldResetScrollOnNavigation, +} from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { cn } from "../lib/utils"; @@ -63,10 +67,13 @@ export function Layout() { const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); + const navigationType = useNavigationType(); const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); const isCompanySettingsRoute = location.pathname.includes("/company/settings"); const onboardingTriggered = useRef(false); const lastMainScrollTop = useRef(0); + const previousPathname = useRef(null); + const mainContentRef = useRef(null); const [mobileNavVisible, setMobileNavVisible] = useState(true); const [instanceSettingsTarget, setInstanceSettingsTarget] = useState(() => readRememberedInstanceSettingsPath()); const [shortcutsOpen, setShortcutsOpen] = useState(false); @@ -143,12 +150,21 @@ export function Layout() { ]); const togglePanel = togglePanelVisible; + const openSearch = useCallback(() => { + document.dispatchEvent(new KeyboardEvent("keydown", { + key: "k", + metaKey: true, + bubbles: true, + cancelable: true, + })); + }, []); useCompanyPageMemory(); useKeyboardShortcuts({ enabled: keyboardShortcutsEnabled, onNewIssue: () => openNewIssue(), + onSearch: openSearch, onToggleSidebar: toggleSidebar, onTogglePanel: togglePanel, onShowShortcuts: () => setShortcutsOpen(true), @@ -268,10 +284,24 @@ export function Layout() { useEffect(() => { if (typeof document === "undefined") return; - const mainContent = document.getElementById("main-content"); + const mainContent = mainContentRef.current; return scheduleMainContentFocus(mainContent); }, [location.pathname]); + useEffect(() => { + const shouldResetScroll = shouldResetScrollOnNavigation({ + previousPathname: previousPathname.current, + pathname: location.pathname, + navigationType, + state: location.state, + }); + + previousPathname.current = location.pathname; + + if (!shouldResetScroll) return; + resetNavigationScroll(mainContentRef.current); + }, [location.pathname, navigationType]); + return (
()); - const shouldFetchLiveRuns = liveRunsData === undefined; - const shouldFetchActiveRun = activeRunData === undefined; - const { data: fetchedLiveRuns } = useQuery({ + const { data: liveRuns } = useQuery({ queryKey: queryKeys.issues.liveRuns(issueId), queryFn: () => heartbeatsApi.liveRunsForIssue(issueId), - enabled: !!issueId && shouldFetchLiveRuns, + enabled: !!issueId, refetchInterval: 3000, }); - const { data: fetchedActiveRun } = useQuery({ + const { data: activeRun } = useQuery({ queryKey: queryKeys.issues.activeRun(issueId), queryFn: () => heartbeatsApi.activeRunForIssue(issueId), - enabled: !!issueId && shouldFetchActiveRun, + enabled: !!issueId, refetchInterval: 3000, }); - const liveRuns = liveRunsData ?? fetchedLiveRuns; - const activeRun = activeRunData ?? fetchedActiveRun; - const runs = useMemo(() => { const deduped = new Map(); for (const run of liveRuns ?? []) { diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index fa8d46de5bd..f30db99101c 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -1,17 +1,60 @@ // @vitest-environment node -import { describe, expect, it } from "vitest"; +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared"; import { ThemeProvider } from "../context/ThemeContext"; import { MarkdownBody } from "./MarkdownBody"; +import { queryKeys } from "../lib/queryKeys"; + +const mockIssuesApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +vi.mock("@/lib/router", () => ({ + Link: ({ children, to }: { children: ReactNode; to: string }) => {children}, +})); + +vi.mock("../api/issues", () => ({ + issuesApi: mockIssuesApi, +})); + +function renderMarkdown(children: string, seededIssues: Array<{ identifier: string; status: string }> = []) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + for (const issue of seededIssues) { + queryClient.setQueryData(queryKeys.issues.detail(issue.identifier), { + id: issue.identifier, + identifier: issue.identifier, + status: issue.status, + }); + } + + return renderToStaticMarkup( + + + {children} + + , + ); +} describe("MarkdownBody", () => { it("renders markdown images without a resolver", () => { const html = renderToStaticMarkup( - - {"![](/api/attachments/test/content)"} - , + + + {"![](/api/attachments/test/content)"} + + , ); expect(html).toContain(''); @@ -19,11 +62,13 @@ describe("MarkdownBody", () => { it("resolves relative image paths when a resolver is provided", () => { const html = renderToStaticMarkup( - - `/resolved/${src}`}> - {"![Org chart](images/org-chart.png)"} - - , + + + `/resolved/${src}`}> + {"![Org chart](images/org-chart.png)"} + + + , ); expect(html).toContain('src="/resolved/images/org-chart.png"'); @@ -32,11 +77,13 @@ describe("MarkdownBody", () => { it("renders agent, project, and skill mentions as chips", () => { const html = renderToStaticMarkup( - - - {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} - - , + + + + {`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`} + + + , ); expect(html).toContain('href="/agents/agent-123"'); @@ -48,4 +95,80 @@ describe("MarkdownBody", () => { expect(html).toContain('href="/skills/skill-789"'); expect(html).toContain('data-mention-kind="skill"'); }); + + it("uses soft-break styling by default", () => { + const html = renderMarkdown("First line\nSecond line"); + + expect(html).toContain("First line
"); + expect(html).toContain("Second line"); + }); + + it("can opt out of soft-break styling", () => { + const html = renderToStaticMarkup( + + + + {"First line\nSecond line"} + + + , + ); + + expect(html).not.toContain("
"); + }); + + it("does not inject extra line-break nodes into nested lists", () => { + const html = renderMarkdown("1. Parent item\n - child a\n - child b\n\n2. Second item"); + + expect(html).not.toContain("[&_p]:whitespace-pre-line"); + expect(html).not.toContain("Parent item
"); + expect(html).toContain("
    "); + expect(html).toContain("
      "); + }); + + it("linkifies bare issue identifiers in markdown text", () => { + const html = renderMarkdown("Depends on PAP-1271 for the hover state.", [ + { identifier: "PAP-1271", status: "done" }, + ]); + + expect(html).toContain('href="/issues/PAP-1271"'); + expect(html).toContain("text-green-600"); + expect(html).toContain(">PAP-1271<"); + }); + + it("rewrites full issue URLs to internal issue links", () => { + const html = renderMarkdown("See http://localhost:3100/PAP/issues/PAP-1179.", [ + { identifier: "PAP-1179", status: "blocked" }, + ]); + + expect(html).toContain('href="/issues/PAP-1179"'); + expect(html).toContain("text-red-600"); + expect(html).toContain(">http://localhost:3100/PAP/issues/PAP-1179<"); + }); + + it("linkifies issue identifiers inside inline code spans", () => { + const html = renderMarkdown("Reference `PAP-1271` here.", [ + { identifier: "PAP-1271", status: "done" }, + ]); + + expect(html).toContain('href="/issues/PAP-1271"'); + expect(html).toContain("PAP-1271"); + expect(html).toContain("text-green-600"); + }); + + it("can opt out of issue reference linkification for offline previews", () => { + const html = renderToStaticMarkup( + + + + {"Depends on PAP-1271 and [manual link](PAP-1271)."} + + + , + ); + + expect(html).not.toContain('href="/issues/PAP-1271"'); + expect(html).toContain("Depends on PAP-1271"); + expect(html).toContain('href="PAP-1271"'); + }); }); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index a454260787f..c9fdf85988a 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,14 +1,23 @@ import { isValidElement, useEffect, useId, useState, type ReactNode } from "react"; -import Markdown, { type Components } from "react-markdown"; +import { useQuery } from "@tanstack/react-query"; +import Markdown, { type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; import { useTheme } from "../context/ThemeContext"; import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { Link } from "@/lib/router"; +import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference"; +import { remarkSoftBreaks } from "../lib/remark-soft-breaks"; +import { StatusIcon } from "./StatusIcon"; interface MarkdownBodyProps { children: string; className?: string; style?: React.CSSProperties; + softBreaks?: boolean; + linkIssueReferences?: boolean; /** Optional resolver for relative image paths (e.g. within export packages) */ resolveImageSrc?: (src: string) => string | null; /** Called when a user clicks an inline image */ @@ -17,6 +26,29 @@ interface MarkdownBodyProps { let mermaidLoaderPromise: Promise | null = null; +function MarkdownIssueLink({ + issuePathId, + href, + children, +}: { + issuePathId: string; + href: string; + children: ReactNode; +}) { + const { data } = useQuery({ + queryKey: queryKeys.issues.detail(issuePathId), + queryFn: () => issuesApi.get(issuePathId), + staleTime: 60_000, + }); + + return ( + + {data ? : null} + {children} + + ); +} + function loadMermaid() { if (!mermaidLoaderPromise) { mermaidLoaderPromise = import("mermaid").then((module) => module.default); @@ -94,8 +126,23 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b ); } -export function MarkdownBody({ children, className, style, resolveImageSrc, onImageClick }: MarkdownBodyProps) { +export function MarkdownBody({ + children, + className, + style, + softBreaks = true, + linkIssueReferences = true, + resolveImageSrc, + onImageClick, +}: MarkdownBodyProps) { const { theme } = useTheme(); + const remarkPlugins: NonNullable = [remarkGfm]; + if (linkIssueReferences) { + remarkPlugins.push(remarkLinkIssueReferences); + } + if (softBreaks) { + remarkPlugins.push(remarkSoftBreaks); + } const components: Components = { pre: ({ node: _node, children: preChildren, ...preProps }) => { const mermaidSource = extractMermaidSource(preChildren); @@ -105,6 +152,15 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm return
      {preChildren}
      ; }, a: ({ href, children: linkChildren }) => { + const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null; + if (issueRef) { + return ( + + {linkChildren} + + ); + } + const parsed = href ? parseMentionChipHref(href) : null; if (parsed) { const targetHref = parsed.kind === "project" @@ -159,7 +215,7 @@ export function MarkdownBody({ children, className, style, resolveImageSrc, onIm )} style={style} > - url}> + url}> {children}
diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index a2b187283d5..3e0431557e0 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -3,7 +3,16 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { computeMentionMenuPosition, findMentionMatch, MarkdownEditor } from "./MarkdownEditor"; +import { buildSkillMentionHref } from "@paperclipai/shared"; +import { + computeMentionMenuPosition, + findClosestAutocompleteAnchor, + findMentionMatch, + isSameAutocompleteSession, + MarkdownEditor, + placeCaretAfterMentionAnchor, + shouldAcceptAutocompleteKey, +} from "./MarkdownEditor"; const mdxEditorMockState = vi.hoisted(() => ({ emitMountEmptyReset: false, @@ -213,4 +222,94 @@ describe("MarkdownEditor", () => { it("still rejects slash commands once spaces are typed", () => { expect(findMentionMatch("/open issue", "/open issue".length)).toBeNull(); }); + + it("does not treat Enter as skill autocomplete accept", () => { + expect(shouldAcceptAutocompleteKey("Enter", "skill")).toBe(false); + expect(shouldAcceptAutocompleteKey("Enter", "skill", true)).toBe(true); + expect(shouldAcceptAutocompleteKey("Enter", "mention")).toBe(true); + expect(shouldAcceptAutocompleteKey("Tab", "skill")).toBe(true); + }); + + it("keeps the same autocomplete session active while the slash query is unchanged", () => { + const textNode = document.createTextNode("/agent"); + expect(isSameAutocompleteSession( + { + trigger: "skill", + marker: "/", + query: "agent", + textNode, + atPos: 0, + endPos: 6, + }, + { + trigger: "skill", + marker: "/", + query: "agent", + textNode, + atPos: 0, + endPos: 6, + }, + )).toBe(true); + + expect(isSameAutocompleteSession( + { + trigger: "skill", + marker: "/", + query: "agent", + textNode, + atPos: 0, + endPos: 6, + }, + { + trigger: "skill", + marker: "/", + query: "agent-browser", + textNode, + atPos: 0, + endPos: 14, + }, + )).toBe(false); + }); + + it("finds skill anchors by mention metadata instead of visible text", () => { + const editable = document.createElement("div"); + const skillLink = document.createElement("a"); + skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser")); + skillLink.textContent = "/agent-browser "; + editable.appendChild(skillLink); + + const found = findClosestAutocompleteAnchor(editable, { + id: "skill:skill-123", + kind: "skill", + skillId: "skill-123", + key: "agent-browser", + name: "Agent Browser", + slug: "agent-browser", + description: null, + href: buildSkillMentionHref("skill-123", "agent-browser"), + aliases: ["agent-browser", "Agent Browser"], + }); + + expect(found).toBe(skillLink); + }); + + it("places the caret after the mention's trailing space when present", () => { + const editable = document.createElement("div"); + editable.contentEditable = "true"; + document.body.appendChild(editable); + + const skillLink = document.createElement("a"); + skillLink.setAttribute("href", buildSkillMentionHref("skill-123", "agent-browser")); + skillLink.textContent = "/agent-browser"; + const trailingSpace = document.createTextNode(" "); + editable.append(skillLink, trailingSpace); + + expect(placeCaretAfterMentionAnchor(skillLink)).toBe(true); + + const selection = window.getSelection(); + expect(selection?.anchorNode).toBe(trailingSpace); + expect(selection?.anchorOffset).toBe(1); + + editable.remove(); + }); }); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index d0c97502a4a..95a4596e304 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -297,6 +297,102 @@ function autocompleteMarkdown(option: AutocompleteOption): string { return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option); } +export function shouldAcceptAutocompleteKey( + key: string, + trigger: MentionState["trigger"] | null, + skillEnterArmed = false, +): boolean { + if (key === "Tab") return true; + if (key !== "Enter") return false; + return trigger === "mention" || (trigger === "skill" && skillEnterArmed); +} + +export function isSameAutocompleteSession( + left: Pick | null, + right: Pick | null, +): boolean { + if (!left || !right) return false; + return left.trigger === right.trigger + && left.marker === right.marker + && left.query === right.query + && left.textNode === right.textNode + && left.atPos === right.atPos + && left.endPos === right.endPos; +} + +function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string): boolean { + const parsed = parseMentionChipHref(href); + if (!parsed) return false; + + if (option.kind === "skill") { + return parsed.kind === "skill" && parsed.skillId === option.skillId; + } + + if (option.kind === "project" && option.projectId) { + return parsed.kind === "project" && parsed.projectId === option.projectId; + } + + const agentId = option.agentId ?? option.id.replace(/^agent:/, ""); + return parsed.kind === "agent" && parsed.agentId === agentId; +} + +export function findClosestAutocompleteAnchor( + editable: HTMLElement, + option: AutocompleteOption, + origin?: Pick | null, +): HTMLAnchorElement | null { + const matchingMentions = Array.from(editable.querySelectorAll("a")) + .filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement) + .filter((link) => autocompleteOptionMatchesLink(option, link.getAttribute("href") ?? "")); + + if (matchingMentions.length === 0) return null; + if (!origin) return matchingMentions[0] ?? null; + + const containerRect = editable.getBoundingClientRect(); + return matchingMentions.sort((a, b) => { + const rectA = a.getBoundingClientRect(); + const rectB = b.getBoundingClientRect(); + const leftA = rectA.left - containerRect.left; + const topA = rectA.top - containerRect.top; + const leftB = rectB.left - containerRect.left; + const topB = rectB.top - containerRect.top; + const distA = Math.hypot(leftA - origin.left, topA - origin.top); + const distB = Math.hypot(leftB - origin.left, topB - origin.top); + return distA - distB; + })[0] ?? null; +} + +export function placeCaretAfterMentionAnchor(target: HTMLAnchorElement): boolean { + const selection = window.getSelection(); + if (!selection) return false; + + const range = document.createRange(); + const nextSibling = target.nextSibling; + if (nextSibling?.nodeType === Node.TEXT_NODE) { + const text = nextSibling.textContent ?? ""; + if (text.startsWith(" ")) { + range.setStart(nextSibling, 1); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return true; + } + if (text.length > 0) { + range.setStart(nextSibling, 0); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return true; + } + } + + range.setStartAfter(target); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + return true; +} + /** Replace the active autocomplete token in the markdown string with the selected token. */ function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string { const search = `${state.marker}${state.query}`; @@ -346,6 +442,7 @@ export const MarkdownEditor = forwardRef const [mentionState, setMentionState] = useState(null); const mentionStateRef = useRef(null); const [mentionIndex, setMentionIndex] = useState(0); + const skillEnterArmedRef = useRef(false); const mentionActive = mentionState !== null && ( (mentionState.trigger === "mention" && Boolean(mentions?.length)) || (mentionState.trigger === "skill" && slashCommands.length > 0) @@ -509,6 +606,7 @@ export const MarkdownEditor = forwardRef const checkMention = useCallback(() => { if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) { mentionStateRef.current = null; + skillEnterArmedRef.current = false; setMentionState(null); return; } @@ -519,6 +617,7 @@ export const MarkdownEditor = forwardRef && (!mentions || mentions.length === 0) ) { mentionStateRef.current = null; + skillEnterArmedRef.current = false; setMentionState(null); return; } @@ -528,16 +627,18 @@ export const MarkdownEditor = forwardRef && slashCommands.length === 0 ) { mentionStateRef.current = null; + skillEnterArmedRef.current = false; setMentionState(null); return; } + const previous = mentionStateRef.current; + const sameSession = isSameAutocompleteSession(previous, result); mentionStateRef.current = result; - if (result) { - setMentionState(result); + if (!sameSession) { + skillEnterArmedRef.current = false; setMentionIndex(0); - } else { - setMentionState(null); } + setMentionState(result); }, [mentions, slashCommands.length]); useEffect(() => { @@ -548,21 +649,11 @@ export const MarkdownEditor = forwardRef // also fires after typing (e.g. space to dismiss). const onInput = () => requestAnimationFrame(checkMention); - let selRafId: number | null = null; - const onSelectionChange = () => { - if (selRafId !== null) return; - selRafId = requestAnimationFrame(() => { - selRafId = null; - checkMention(); - }); - }; - - document.addEventListener("selectionchange", onSelectionChange); + document.addEventListener("selectionchange", checkMention); el?.addEventListener("input", onInput, true); return () => { - document.removeEventListener("selectionchange", onSelectionChange); + document.removeEventListener("selectionchange", checkMention); el?.removeEventListener("input", onInput, true); - if (selRafId !== null) cancelAnimationFrame(selRafId); }; }, [checkMention, mentions, slashCommands.length]); @@ -589,24 +680,16 @@ export const MarkdownEditor = forwardRef const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!editable) return; decorateProjectMentions(); - let rafId: number | null = null; const observer = new MutationObserver(() => { - if (rafId !== null) return; - rafId = requestAnimationFrame(() => { - rafId = null; - decorateProjectMentions(); - }); + decorateProjectMentions(); }); observer.observe(editable, { subtree: true, childList: true, characterData: true, }); - return () => { - observer.disconnect(); - if (rafId !== null) cancelAnimationFrame(rafId); - }; - }, [decorateProjectMentions]); + return () => observer.disconnect(); + }, [decorateProjectMentions, value]); const selectMention = useCallback( (option: AutocompleteOption) => { @@ -623,65 +706,28 @@ export const MarkdownEditor = forwardRef onChange(next); } - requestAnimationFrame(() => { - requestAnimationFrame(() => { - const editable = containerRef.current?.querySelector('[contenteditable="true"]'); - if (!(editable instanceof HTMLElement)) return; - decorateProjectMentions(); - editable.focus(); + const restoreSelection = (attemptsRemaining: number) => { + const editable = containerRef.current?.querySelector('[contenteditable="true"]'); + if (!(editable instanceof HTMLElement)) return; - const mentionHref = option.kind === "skill" - ? option.href - : option.kind === "project" && option.projectId - ? buildProjectMentionHref(option.projectId, option.projectColor ?? null) - : buildAgentMentionHref( - option.agentId ?? option.id.replace(/^agent:/, ""), - option.agentIcon ?? null, - ); - const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`; - const matchingMentions = Array.from(editable.querySelectorAll("a")) - .filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement) - .filter((link) => { - const href = link.getAttribute("href") ?? ""; - return href === mentionHref && link.textContent === expectedLabel; - }); - const containerRect = containerRef.current?.getBoundingClientRect(); - const target = matchingMentions.sort((a, b) => { - const rectA = a.getBoundingClientRect(); - const rectB = b.getBoundingClientRect(); - const leftA = containerRect ? rectA.left - containerRect.left : rectA.left; - const topA = containerRect ? rectA.top - containerRect.top : rectA.top; - const leftB = containerRect ? rectB.left - containerRect.left : rectB.left; - const topB = containerRect ? rectB.top - containerRect.top : rectB.top; - const distA = Math.hypot(leftA - state.left, topA - state.top); - const distB = Math.hypot(leftB - state.left, topB - state.top); - return distA - distB; - })[0] ?? null; - if (!target) return; + decorateProjectMentions(); + editable.focus(); - const selection = window.getSelection(); - if (!selection) return; - const range = document.createRange(); - const nextSibling = target.nextSibling; - if (nextSibling?.nodeType === Node.TEXT_NODE) { - const text = nextSibling.textContent ?? ""; - if (text.startsWith(" ")) { - range.setStart(nextSibling, 1); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - return; - } + const target = findClosestAutocompleteAnchor(editable, option, state); + if (!target) { + if (attemptsRemaining > 0) { + requestAnimationFrame(() => restoreSelection(attemptsRemaining - 1)); } + return; + } - range.setStartAfter(target); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - }); - }); + placeCaretAfterMentionAnchor(target); + }; + + requestAnimationFrame(() => restoreSelection(4)); mentionStateRef.current = null; + skillEnterArmedRef.current = false; setMentionState(null); }, [decorateProjectMentions, onChange], @@ -737,6 +783,7 @@ export const MarkdownEditor = forwardRef if (mentionActive) { if (e.key === " " && mentionStateRef.current?.trigger === "skill") { mentionStateRef.current = null; + skillEnterArmedRef.current = false; setMentionState(null); return; } @@ -745,6 +792,7 @@ export const MarkdownEditor = forwardRef e.preventDefault(); e.stopPropagation(); mentionStateRef.current = null; + skillEnterArmedRef.current = false; setMentionState(null); return; } @@ -753,16 +801,24 @@ export const MarkdownEditor = forwardRef if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); + skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill"; setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); + skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill"; setMentionIndex((prev) => Math.max(prev - 1, 0)); return; } - if (e.key === "Enter" || e.key === "Tab") { + if ( + shouldAcceptAutocompleteKey( + e.key, + mentionStateRef.current?.trigger ?? null, + skillEnterArmedRef.current, + ) + ) { e.preventDefault(); e.stopPropagation(); selectMention(filteredMentions[mentionIndex]); @@ -868,7 +924,12 @@ export const MarkdownEditor = forwardRef e.preventDefault(); // prevent blur selectMention(option); }} - onMouseEnter={() => setMentionIndex(i)} + onMouseEnter={() => { + if (mentionStateRef.current?.trigger === "skill") { + skillEnterArmedRef.current = true; + } + setMentionIndex(i); + }} > {option.kind === "skill" ? ( diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index daa17318665..6bfefdfa2b1 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -9,6 +9,7 @@ import { } from "lucide-react"; import { useCompany } from "../context/CompanyContext"; import { useDialog } from "../context/DialogContext"; +import { SIDEBAR_SCROLL_RESET_STATE } from "../lib/navigation-scroll"; import { cn } from "../lib/utils"; import { useInboxBadge } from "../hooks/useInboxBadge"; @@ -92,6 +93,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { cn( "relative flex min-w-0 flex-col items-center justify-center gap-1 rounded-md text-[10px] font-medium transition-colors", diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index 3e652a02220..b81b30e4327 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -222,18 +222,6 @@ async function flush() { }); } -async function waitForValue(getValue: () => T | null | undefined, attempts = 10): Promise { - for (let attempt = 0; attempt < attempts; attempt += 1) { - const value = getValue(); - if (value != null) { - return value; - } - await flush(); - } - - throw new Error("Timed out waiting for value"); -} - function renderDialog(container: HTMLDivElement) { const queryClient = new QueryClient({ defaultOptions: { @@ -384,6 +372,29 @@ describe("NewIssueDialog", () => { act(() => root.unmount()); }); + it("keeps the mobile dialog bounded with an internal flexible scroll region", async () => { + const { root } = renderDialog(container); + await flush(); + + const dialogContent = Array.from(container.querySelectorAll("div")).find((element) => + typeof element.className === "string" && element.className.includes("max-h-[calc(100dvh-2rem)]"), + ); + expect(dialogContent?.className).toContain("h-[calc(100dvh-2rem)]"); + expect(dialogContent?.className).toContain("overflow-hidden"); + + const titleInput = container.querySelector('textarea[placeholder="Issue title"]'); + const descriptionInput = container.querySelector('textarea[aria-label="Add description..."]'); + const bodyScrollRegion = Array.from(container.querySelectorAll("div")).find((element) => + typeof element.className === "string" && element.className.includes("overscroll-contain"), + ); + expect(bodyScrollRegion?.className).toContain("flex-1"); + expect(bodyScrollRegion?.className).toContain("overflow-y-auto"); + expect(bodyScrollRegion?.contains(titleInput ?? null)).toBe(true); + expect(bodyScrollRegion?.contains(descriptionInput ?? null)).toBe(true); + + act(() => root.unmount()); + }); + it("warns when a sub-issue stops matching the parent workspace", async () => { mockProjectsApi.list.mockResolvedValue([ { @@ -430,16 +441,17 @@ describe("NewIssueDialog", () => { const { root } = renderDialog(container); await flush(); + await flush(); expect(container.textContent).not.toContain("will no longer use the parent issue workspace"); - const modeSelect = await waitForValue( - () => container.querySelector("select") as HTMLSelectElement | null, - ); + const selects = Array.from(container.querySelectorAll("select")); + const modeSelect = selects[0] as HTMLSelectElement | undefined; + expect(modeSelect).not.toBeUndefined(); await act(async () => { - modeSelect.value = "shared_workspace"; - modeSelect.dispatchEvent(new Event("change", { bubbles: true })); + modeSelect!.value = "shared_workspace"; + modeSelect!.dispatchEvent(new Event("change", { bubbles: true })); }); await flush(); diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index b3436a0337c..7798c2348aa 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -946,9 +946,9 @@ export function NewIssueDialog() { showCloseButton={false} aria-describedby={undefined} className={cn( - "p-0 gap-0 flex flex-col max-h-[calc(100dvh-2rem)]", + "flex h-[calc(100dvh-2rem)] max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-auto", expanded - ? "sm:max-w-2xl h-[calc(100dvh-2rem)]" + ? "sm:max-w-2xl sm:h-[calc(100dvh-2rem)]" : "sm:max-w-lg" )} onKeyDown={handleKeyDown} @@ -1056,9 +1056,10 @@ export function NewIssueDialog() {
- {/* Title */} -
-