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 00000000..0ba12236 --- /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 4a1dd669..31780e74 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 c45776e7..bff4b1c7 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 00000000..ccc4b6a0 --- /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 1826e376..4de796b5 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 00000000..d75452ab --- /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 df1a91b8..b66c20fa 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 3245da05..87de8599 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -2,10 +2,20 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { + agents, + companies, + createDb, + projects, + routines, + routineTriggers, +} from "@paperclipai/db"; import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, + pauseSeededScheduledRoutines, readSourceAttachmentBody, rebindWorkspaceCwd, resolveSourceConfigPath, @@ -28,9 +38,21 @@ 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 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); @@ -823,3 +845,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 580e7e08..6434ede0 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 dc720f63..d07b9a70 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 83ff089b..b7d2dcbc 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 bdbf739f..7e1adce5 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 d9b325a8..62158e05 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 04743b48..9bc9655b 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 d2b6c5f7..5d4a0721 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 963ae5e8..be7b1ab7 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 00000000..3bcc8015 --- /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 11739459..b2208798 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 e5c26180..404d4ea5 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 6f945656..c124b447 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 9d58d50a..a7b8d7fb 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 724496e9..f86ab47b 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 bdb098b3..32f27ea9 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 9648828c..f57f0f73 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 68b33bd3..50ae47e1 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 ff30263b..927e7cf3 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/adapters/creating-an-adapter.md b/docs/adapters/creating-an-adapter.md index ae5e4ccb..c81274f7 100644 --- a/docs/adapters/creating-an-adapter.md +++ b/docs/adapters/creating-an-adapter.md @@ -203,6 +203,43 @@ export const sessionCodec: AdapterSessionCodec = { }; ``` +## Capability Flags + +Adapters can declare what "local" capabilities they support by setting optional fields on the `ServerAdapterModule`. The server and UI use these flags to decide which features to enable for agents using the adapter (instructions bundle editor, skills sync, JWT auth, etc.). + +| Flag | Type | Default | What it controls | +|------|------|---------|------------------| +| `supportsLocalAgentJwt` | `boolean` | `false` | Whether heartbeat generates a local JWT for the agent | +| `supportsInstructionsBundle` | `boolean` | `false` | Managed instructions bundle (AGENTS.md) — server-side resolution + UI editor | +| `instructionsPathKey` | `string` | `"instructionsFilePath"` | The `adapterConfig` key that holds the instructions file path | +| `requiresMaterializedRuntimeSkills` | `boolean` | `false` | Whether runtime skill entries must be written to disk before execution | + +These flags are exposed via `GET /api/adapters` in a `capabilities` object, along with a derived `supportsSkills` flag (true when `listSkills` or `syncSkills` is defined). + +### Example + +```ts +export function createServerAdapter(): ServerAdapterModule { + return { + type: "my_k8s_adapter", + execute: myExecute, + testEnvironment: myTestEnvironment, + listSkills: myListSkills, + syncSkills: mySyncSkills, + + // Capability flags + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, + }; +} +``` + +With these flags set, the Paperclip UI will automatically show the instructions bundle editor, skills management tab, and working directory field for agents using this adapter — no Paperclip source changes required. + +If capability flags are not set, the server falls back to legacy hardcoded lists for built-in adapter types. External adapters that omit the flags will default to `false` for all capabilities. + ## Skills Injection Make Paperclip skills discoverable to your agent runtime without writing to the agent's working directory: diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 448ab7bb..bb3cf17f 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 ae567f49..7d5e6032 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 bc5e4bcc..5ebef728 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 874477c1..3bc42334 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 1e0d2467..bb40fa30 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 58ffa103..9ab8f60c 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 62e395b0..bcdc4ba0 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 83dbe06f..3c0e0db7 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 429143d5..e02b9efe 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; } @@ -328,6 +328,36 @@ export interface ServerAdapterModule { * resolved inside this method — the caller receives a fully hydrated schema. */ getConfigSchema?: () => Promise | AdapterConfigSchema; + + // --------------------------------------------------------------------------- + // Adapter capability flags + // + // These allow adapter plugins to declare what "local" capabilities they + // support, replacing hardcoded type lists in the server and UI. + // All flags are optional — when undefined, the server falls back to + // legacy hardcoded lists for built-in adapters. + // --------------------------------------------------------------------------- + + /** + * Adapter supports managed instructions bundle (AGENTS.md files). + * When true, the server uses instructionsPathKey (default "instructionsFilePath") + * to resolve the instructions config key, and the UI shows the bundle editor. + * Built-in local adapters default to true; external plugins must opt in. + */ + supportsInstructionsBundle?: boolean; + + /** + * The adapterConfig key that holds the instructions file path. + * Defaults to "instructionsFilePath" when supportsInstructionsBundle is true. + */ + instructionsPathKey?: string; + + /** + * Adapter needs runtime skill entries materialized (written to disk) + * before being passed via config. Used by adapters that scan a directory + * rather than reading config.paperclipRuntimeSkills. + */ + requiresMaterializedRuntimeSkills?: boolean; } // --------------------------------------------------------------------------- @@ -372,6 +402,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 ae5ea3ab..f2da174e 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 5da522a6..6c5c2782 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 00000000..ce719799 --- /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 b88cc54e..75446393 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 ca795cb5..cbafb2d1 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 00000000..c291ae53 --- /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 00000000..76756814 --- /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 c6ba3e9b..f34c09ca 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 b2b33e4a..6114cae6 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 1a7aa6a7..5d73b3ba 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 64af601b..f19ed007 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 00000000..734d1687 --- /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 7c8f0d9c..8721ba76 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 66ff2a4a..7382c67a 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 dcdc87c5..2ea9b070 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 ea76a2b6..e1e88724 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 f07dc646..c11822c9 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 cf4a2633..200e2981 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 00000000..94bcb334 --- /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 00000000..55d557df --- /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 61d03ee4..82dffcf5 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 610bcb47..4c100505 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 48687878..efa1bdee 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 521ccf38..f62d2777 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 9b125165..8ec63026 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, @@ -79,6 +80,7 @@ export { type CompanyStatus, type DeploymentMode, type DeploymentExposure, + type BindMode, type AuthBaseUrlMode, type AgentStatus, type AgentAdapterType, @@ -149,6 +151,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, @@ -189,6 +201,7 @@ export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, + BackupRetentionPolicy, Agent, AgentAccessState, AgentChainOfCommandEntry, @@ -370,6 +383,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 00000000..eeda9c50 --- /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 a8d6aefb..9c4181cd 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 5e8dd217..6115f049 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 888740d3..13625a68 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 70599868..4e83c925 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 5634c8f6..930e183f 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 756a6b92..a26ef638 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -7,6 +7,7 @@ import { stdin, stdout } from "node:process"; import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs"; 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, @@ -14,6 +15,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; @@ -62,13 +76,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); } @@ -78,6 +115,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, @@ -94,13 +141,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)"); } @@ -108,7 +178,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 9c129b34..90efb322 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 new file mode 100644 index 00000000..17a99909 --- /dev/null +++ b/scripts/ensure-workspace-package-links.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env -S node --import tsx +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; +}; + +function readJsonFile(filePath: string): Record { + return JSON.parse(readFileSync(filePath, "utf8")) as Record; +} + +function discoverWorkspacePackagePaths(rootDir: string): Map { + const packagePaths = new Map(); + const ignoredDirNames = new Set([".git", ".paperclip", "dist", "node_modules"]); + + function visit(dirPath: string) { + const packageJsonPath = path.join(dirPath, "package.json"); + if (existsSync(packageJsonPath)) { + const packageJson = readJsonFile(packageJsonPath); + if (typeof packageJson.name === "string" && packageJson.name.length > 0) { + packagePaths.set(packageJson.name, dirPath); + } + } + + for (const entry of readdirSync(dirPath, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (ignoredDirNames.has(entry.name)) continue; + visit(path.join(dirPath, entry.name)); + } + } + + visit(path.join(rootDir, "packages")); + visit(path.join(rootDir, "server")); + visit(path.join(rootDir, "ui")); + visit(path.join(rootDir, "cli")); + + return packagePaths; +} + +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 = { + ...(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 = workspacePackagePaths.get(packageName); + if (!expectedPath) continue; + + 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, + }); + } + + return mismatches; +} + +async function ensureWorkspaceLinksCurrent(workspaceDir: string) { + const mismatches = findWorkspaceLinkMismatches(workspaceDir); + if (mismatches.length === 0) return; + + 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}`, + ); + } + + 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 = findWorkspaceLinkMismatches(workspaceDir); + if (remainingMismatches.length === 0) return; + + throw new Error( + `Workspace relink did not repair all ${workspaceDir} package links: ${remainingMismatches.map((item) => item.packageName).join(", ")}`, + ); +} + +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 00000000..2645d77f --- /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 1dd481b1..9fbd3ccc 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -237,6 +237,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 ?? [], @@ -321,20 +323,6 @@ if ! run_isolated_worktree_init; then 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 0235fdbc..ffd343df 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-registry.test.ts b/server/src/__tests__/adapter-registry.test.ts index 6f7b0973..4e473df5 100644 --- a/server/src/__tests__/adapter-registry.test.ts +++ b/server/src/__tests__/adapter-registry.test.ts @@ -95,6 +95,51 @@ describe("server adapter registry", () => { ]); }); + it("exposes capability flags from registered adapters", () => { + const adapterWithCaps: ServerAdapterModule = { + type: "external_test", + execute: async () => ({ exitCode: 0, signal: null, timedOut: false }), + testEnvironment: async () => ({ + adapterType: "external_test", + status: "pass" as const, + checks: [], + testedAt: new Date(0).toISOString(), + }), + supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "customPathKey", + requiresMaterializedRuntimeSkills: true, + }; + + registerServerAdapter(adapterWithCaps); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBe(true); + expect(resolved!.instructionsPathKey).toBe("customPathKey"); + expect(resolved!.requiresMaterializedRuntimeSkills).toBe(true); + expect(resolved!.supportsLocalAgentJwt).toBe(true); + }); + + it("returns undefined for capability flags on adapters that do not set them", () => { + registerServerAdapter(externalAdapter); + + const resolved = findActiveServerAdapter("external_test"); + expect(resolved).not.toBeNull(); + expect(resolved!.supportsInstructionsBundle).toBeUndefined(); + expect(resolved!.instructionsPathKey).toBeUndefined(); + expect(resolved!.requiresMaterializedRuntimeSkills).toBeUndefined(); + }); + + it("built-in claude_local adapter declares capability flags", () => { + const adapter = findActiveServerAdapter("claude_local"); + expect(adapter).not.toBeNull(); + expect(adapter!.supportsInstructionsBundle).toBe(true); + expect(adapter!.instructionsPathKey).toBe("instructionsFilePath"); + expect(adapter!.requiresMaterializedRuntimeSkills).toBe(false); + expect(adapter!.supportsLocalAgentJwt).toBe(true); + }); + it("switches active adapter behavior back to the builtin when an override is paused", async () => { const builtIn = findServerAdapter("claude_local"); expect(builtIn).not.toBeNull(); diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index c1ce6c3a..eddf3817 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -57,6 +57,75 @@ describe("adapter routes", () => { unregisterServerAdapter("claude_local"); }); + it("GET /api/adapters includes capabilities object for each adapter", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); + + // Every adapter should have a capabilities object + for (const adapter of res.body) { + expect(adapter.capabilities).toBeDefined(); + expect(typeof adapter.capabilities.supportsInstructionsBundle).toBe("boolean"); + expect(typeof adapter.capabilities.supportsSkills).toBe("boolean"); + expect(typeof adapter.capabilities.supportsLocalAgentJwt).toBe("boolean"); + expect(typeof adapter.capabilities.requiresMaterializedRuntimeSkills).toBe("boolean"); + } + }); + + it("GET /api/adapters returns correct capabilities for built-in adapters", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // codex_local has instructions bundle + skills + jwt, no materialized skills + // (claude_local is overridden by beforeEach, so check codex_local instead) + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities).toMatchObject({ + supportsInstructionsBundle: true, + supportsSkills: true, + supportsLocalAgentJwt: true, + requiresMaterializedRuntimeSkills: false, + }); + + // process adapter should have no local capabilities + const processAdapter = res.body.find((a: any) => a.type === "process"); + expect(processAdapter).toBeDefined(); + expect(processAdapter.capabilities).toMatchObject({ + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }); + + // cursor adapter should require materialized runtime skills + const cursorAdapter = res.body.find((a: any) => a.type === "cursor"); + expect(cursorAdapter).toBeDefined(); + expect(cursorAdapter.capabilities.requiresMaterializedRuntimeSkills).toBe(true); + expect(cursorAdapter.capabilities.supportsInstructionsBundle).toBe(true); + }); + + it("GET /api/adapters derives supportsSkills from listSkills/syncSkills presence", async () => { + const app = createApp(); + + const res = await request(app).get("/api/adapters"); + expect(res.status).toBe(200); + + // http adapter has no listSkills/syncSkills + const httpAdapter = res.body.find((a: any) => a.type === "http"); + expect(httpAdapter).toBeDefined(); + expect(httpAdapter.capabilities.supportsSkills).toBe(false); + + // codex_local has listSkills/syncSkills + const codexLocal = res.body.find((a: any) => a.type === "codex_local"); + expect(codexLocal).toBeDefined(); + expect(codexLocal.capabilities.supportsSkills).toBe(true); + }); + it("uses the active adapter when resolving config schema for a paused builtin override", async () => { const app = createApp(); diff --git a/server/src/__tests__/adapter-session-codecs.test.ts b/server/src/__tests__/adapter-session-codecs.test.ts index acac2692..27efe287 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 1f65c26d..8ced2e2f 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 00000000..8f878751 --- /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 6ef45db1..abf51c79 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 91a7dbff..e775ed18 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({ @@ -367,7 +353,7 @@ describe("agent skill routes", () => { }, }); - 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", @@ -403,7 +389,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", @@ -430,7 +416,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", @@ -458,7 +444,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 83d34cf1..af7e1867 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 f40af034..4076639f 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -7,13 +7,23 @@ 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 promptFileFlagIndex = process.argv.indexOf("--append-system-prompt-file"); const appendedSystemPromptFilePath = promptFileFlagIndex >= 0 ? process.argv[promptFileFlagIndex + 1] : null; 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, appendedSystemPromptFilePath, appendedSystemPromptFileContents: appendedSystemPromptFilePath ? fs.readFileSync(appendedSystemPromptFilePath, "utf8") : null, @@ -29,6 +39,18 @@ 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"); @@ -232,47 +254,6 @@ describe("claude execute", () => { } }); - /** - * Regression test for unnecessary file I/O on resumed sessions (Greptile P2). - * - * The combined agent-instructions.md temp file must NOT be written when - * resuming, since the instructions are already baked into the session cache. - */ - it("does not write agent-instructions temp file on a resumed session", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-io-resume-")); - const { workspace, commandPath, restore } = await setupExecuteEnv(root); - const instructionsFile = path.join(root, "instructions.md"); - await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); - try { - await execute({ - runId: "run-io-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 () => {}, - }); - // The skills dir lives under HOME/.paperclip/skills — verify no combined - // agent-instructions.md was written anywhere under root on a resume. - const allFiles = await fs.readdir(root, { recursive: true }); - const tempInstructionsWritten = (allFiles as string[]).some((f) => - f.includes("agent-instructions.md"), - ); - expect(tempInstructionsWritten).toBe(false); - } 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, { @@ -320,7 +301,10 @@ describe("claude execute", () => { 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)}/.`, + `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); @@ -403,4 +387,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 f3642565..f71358c1 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 bc3fa439..a86e8c17 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 8649f631..075f2d9b 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 6dbad659..890d7f11 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 d6183067..9babd8b1 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 00000000..461f2aab --- /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 00000000..1c04009d --- /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 77538331..61648323 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 79efdabe..7355f0d4 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 b09d8393..12f8ff7a 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 d4e99a8e..98764772 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 ed2a6885..78b75d3a 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 d576d3ac..386da1e2 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 190cb077..4f8d9fc6 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 7271b499..3d8a649b 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 6a957827..846e3a53 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 80d8641b..ead8ff90 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", @@ -123,7 +117,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 00000000..c6bf9177 --- /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 a434b418..0629686a 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -298,6 +298,51 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { 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(); diff --git a/server/src/__tests__/llms-routes.test.ts b/server/src/__tests__/llms-routes.test.ts new file mode 100644 index 00000000..0bc34bbc --- /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 0f288a84..a68639e3 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 = { @@ -99,11 +99,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) => { @@ -125,9 +121,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); @@ -140,7 +134,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", @@ -165,7 +159,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", @@ -193,7 +187,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", @@ -215,7 +209,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", @@ -238,12 +232,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 13cdebbc..c43648a1 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 895cc02e..25636f6c 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 12a3d837..deb3e110 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 ce1db549..d3a9edea 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 78565645..056e812a 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 2264638c..85826e44 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/registry.ts b/server/src/adapters/registry.ts index f6746227..f6a1afca 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -97,6 +97,9 @@ const claudeLocalAdapter: ServerAdapterModule = { models: claudeModels, listModels: listClaudeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: claudeAgentConfigurationDoc, getQuotaWindows: claudeGetQuotaWindows, }; @@ -112,6 +115,9 @@ const codexLocalAdapter: ServerAdapterModule = { models: codexModels, listModels: listCodexModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: codexAgentConfigurationDoc, getQuotaWindows: codexGetQuotaWindows, }; @@ -127,6 +133,9 @@ const cursorLocalAdapter: ServerAdapterModule = { models: cursorModels, listModels: listCursorModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: cursorAgentConfigurationDoc, }; @@ -140,6 +149,9 @@ const geminiLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("gemini_local") ?? undefined, models: geminiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: geminiAgentConfigurationDoc, }; @@ -149,6 +161,8 @@ const openclawGatewayAdapter: ServerAdapterModule = { testEnvironment: openclawGatewayTestEnvironment, models: openclawGatewayModels, supportsLocalAgentJwt: false, + supportsInstructionsBundle: false, + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: openclawGatewayAgentConfigurationDoc, }; @@ -163,6 +177,9 @@ const openCodeLocalAdapter: ServerAdapterModule = { sessionManagement: getAdapterSessionManagement("opencode_local") ?? undefined, listModels: listOpenCodeModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: openCodeAgentConfigurationDoc, }; @@ -177,6 +194,9 @@ const piLocalAdapter: ServerAdapterModule = { models: [], listModels: listPiModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: true, agentConfigurationDoc: piAgentConfigurationDoc, }; @@ -189,6 +209,9 @@ const hermesLocalAdapter: ServerAdapterModule = { syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, + supportsInstructionsBundle: true, + instructionsPathKey: "instructionsFilePath", + requiresMaterializedRuntimeSkills: false, agentConfigurationDoc: hermesAgentConfigurationDoc, detectModel: () => detectModelFromHermes(), }; diff --git a/server/src/adapters/utils.ts b/server/src/adapters/utils.ts index 6dfccfcb..7c833865 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 71084cc0..21271c98 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 00000000..4e2b5d8d --- /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 955aaa16..0e8db5a6 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 a53bf0dc..e4893765 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -928,7 +928,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 0884b455..20f2ed53 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/adapters.ts b/server/src/routes/adapters.ts index 27e32a06..5effee9d 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -59,6 +59,13 @@ interface AdapterInstallRequest { version?: string; } +interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + interface AdapterInfo { type: string; label: string; @@ -66,6 +73,7 @@ interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** True when an external plugin has replaced a built-in adapter of the same type. */ overriddenBuiltin?: boolean; /** True when the external override for a builtin type is currently paused. */ @@ -103,6 +111,15 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string } } +function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities { + return { + supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false, + supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills), + supportsLocalAgentJwt: adapter.supportsLocalAgentJwt ?? false, + requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false, + }; +} + function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterPluginRecord | undefined, disabledSet: Set): AdapterInfo { const fromDisk = externalRecord ? readAdapterPackageVersionFromDisk(externalRecord) : undefined; return { @@ -112,6 +129,7 @@ function buildAdapterInfo(adapter: ServerAdapterModule, externalRecord: AdapterP modelsCount: (adapter.models ?? []).length, loaded: true, // If it's in the registry, it's loaded disabled: disabledSet.has(adapter.type), + capabilities: buildAdapterCapabilities(adapter), overriddenBuiltin: externalRecord ? BUILTIN_ADAPTER_TYPES.has(adapter.type) : undefined, overridePaused: BUILTIN_ADAPTER_TYPES.has(adapter.type) ? isOverridePaused(adapter.type) : undefined, // Prefer on-disk package.json so the UI reflects bumps without relying on store-only fields. diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 266803ec..7e70e810 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -72,6 +72,8 @@ import { import { getTelemetryClient } from "../telemetry.js"; export function agentRoutes(db: Db) { + // Legacy hardcoded maps — used as fallback when adapter module does not + // declare capability flags explicitly. const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record = { claude_local: "instructionsFilePath", codex_local: "instructionsFilePath", @@ -83,6 +85,20 @@ export function agentRoutes(db: Db) { pi_local: "instructionsFilePath", }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); + + /** Check if an adapter supports the managed instructions bundle. */ + function adapterSupportsInstructionsBundle(adapterType: string): boolean { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.supportsInstructionsBundle !== undefined) return adapter.supportsInstructionsBundle; + return DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(adapterType); + } + + /** Resolve the adapter config key for the instructions file path. */ + function resolveInstructionsPathKey(adapterType: string): string | null { + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.instructionsPathKey) return adapter.instructionsPathKey; + return DEFAULT_INSTRUCTIONS_PATH_KEYS[adapterType] ?? null; + } const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ "instructionsBundleMode", @@ -557,7 +573,7 @@ export function agentRoutes(db: Db) { adapterType: string; adapterConfig: unknown; }>(agent: T): Promise { - if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) { + if (!adapterSupportsInstructionsBundle(agent.adapterType)) { return agent; } @@ -638,7 +654,9 @@ export function agentRoutes(db: Db) { }; } - const ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS = new Set([ + // Legacy hardcoded set — used as fallback when adapter module does not + // declare requiresMaterializedRuntimeSkills explicitly. + const LEGACY_MATERIALIZED_SKILLS_SET = new Set([ "cursor", "gemini_local", "opencode_local", @@ -646,7 +664,11 @@ export function agentRoutes(db: Db) { ]); function shouldMaterializeRuntimeSkillsForAdapter(adapterType: string) { - return ADAPTERS_REQUIRING_MATERIALIZED_RUNTIME_SKILLS.has(adapterType); + const adapter = findActiveServerAdapter(adapterType); + if (adapter?.requiresMaterializedRuntimeSkills !== undefined) { + return adapter.requiresMaterializedRuntimeSkills; + } + return LEGACY_MATERIALIZED_SKILLS_SET.has(adapterType); } async function buildRuntimeSkillConfig( @@ -1609,7 +1631,7 @@ export function agentRoutes(db: Db) { const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; const explicitKey = asNonEmptyString(req.body.adapterConfigKey); - const defaultKey = DEFAULT_INSTRUCTIONS_PATH_KEYS[existing.adapterType] ?? null; + const defaultKey = resolveInstructionsPathKey(existing.adapterType); const adapterConfigKey = explicitKey ?? defaultKey; if (!adapterConfigKey) { res.status(422).json({ @@ -2296,6 +2318,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 +2463,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 +2487,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 99d33abd..3ed20374 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 d1978c9d..22be5f86 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 534bed6e..46c6896f 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 344c262c..6ffd524e 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 4418c76f..3fec0e62 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 a86d7f68..62e99530 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 441b0882..4d6335ef 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 d94922a0..20cca465 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 7856591d..65a12632 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 6f4ba7b5..6a3045a1 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 bb40be79..f7ac19da 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 eac87732..17738e72 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -221,6 +221,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 { @@ -296,13 +307,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 1a52731b..a3ae2ed1 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 d4f73aea..e3178d53 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 baea6138..f14177be 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 0148248b..120d851b 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. @@ -303,6 +314,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 676389b4..02e1761c 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -616,6 +616,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 86bef600..b1a24bb9 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 307aa5ae..95a81707 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/adapters/use-adapter-capabilities.ts b/ui/src/adapters/use-adapter-capabilities.ts new file mode 100644 index 00000000..4653e7de --- /dev/null +++ b/ui/src/adapters/use-adapter-capabilities.ts @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { adaptersApi, type AdapterCapabilities } from "@/api/adapters"; +import { queryKeys } from "@/lib/queryKeys"; + +/** + * Returns a lookup function that resolves adapter capabilities by type. + * + * Capabilities are fetched from the server adapter listing API and cached + * via react-query. When the data is not yet loaded, the lookup returns + * a conservative default (all capabilities false). + */ +export function useAdapterCapabilities(): (type: string) => AdapterCapabilities { + const { data: adapters } = useQuery({ + queryKey: queryKeys.adapters.all, + queryFn: () => adaptersApi.list(), + staleTime: 5 * 60 * 1000, + }); + + const capMap = useMemo(() => { + const map = new Map(); + if (adapters) { + for (const a of adapters) { + map.set(a.type, a.capabilities); + } + } + return map; + }, [adapters]); + + return (type: string): AdapterCapabilities => + capMap.get(type) ?? { + supportsInstructionsBundle: false, + supportsSkills: false, + supportsLocalAgentJwt: false, + requiresMaterializedRuntimeSkills: false, + }; +} diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index b1f43d49..46f887ae 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/adapters.ts b/ui/src/api/adapters.ts index 86705bd4..18b154ef 100644 --- a/ui/src/api/adapters.ts +++ b/ui/src/api/adapters.ts @@ -4,6 +4,13 @@ import { api } from "./client"; +export interface AdapterCapabilities { + supportsInstructionsBundle: boolean; + supportsSkills: boolean; + supportsLocalAgentJwt: boolean; + requiresMaterializedRuntimeSkills: boolean; +} + export interface AdapterInfo { type: string; label: string; @@ -11,6 +18,7 @@ export interface AdapterInfo { modelsCount: number; loaded: boolean; disabled: boolean; + capabilities: AdapterCapabilities; /** Installed version (for external npm adapters) */ version?: string; /** Package name (for external adapters) */ diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts index faabdcf1..045c2b1f 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 26c969e3..feca6161 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 dd74c376..dcf9bf11 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -49,6 +49,8 @@ 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"; +import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities"; /* ---- Create mode values ---- */ @@ -89,15 +91,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 +101,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 +205,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 +221,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 +242,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(() => { @@ -317,8 +270,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) { const adapterType = isCreate ? props.values.adapterType : overlay.adapterType ?? props.agent.adapterType; - const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]); - const isLocal = !NONLOCAL_TYPES.has(adapterType); + const getCapabilities = useAdapterCapabilities(); + const adapterCaps = getCapabilities(adapterType); + const isLocal = adapterCaps.supportsInstructionsBundle || adapterCaps.supportsSkills || adapterCaps.supportsLocalAgentJwt; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 911a109a..b10f69e6 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 00000000..b229cc96 --- /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 82987bf6..f5a0ef75 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 062cbcf7..f4ad17b5 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); - } - }, - [onVote], - ); + 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); + } + } 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 fdc4da2a..27700a0e 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 076b9702..b39b959c 100644 --- a/ui/src/components/InstanceSidebar.tsx +++ b/ui/src/components/InstanceSidebar.tsx @@ -3,6 +3,7 @@ import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from " 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() { @@ -33,6 +34,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 884517b4..00d3df61 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 a1489a7e..516a8c45 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 117e5b13..9f04df9f 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 26db7266..0acbca9a 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 00000000..4bb0048f --- /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 a1fe7e1c..3c92683a 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 c6d33ff2..ed7a5c31 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 4ba0cee2..2b52a042 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 09df3f03..2ba4e92e 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 16e6faf5..e87fa41c 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 198a1a16..9dde9e08 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 c4e02e49..f8a12ce6 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 8b2bad2c..3e1ef1fb 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 937292ad..45d6858d 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.tsx b/ui/src/components/Layout.tsx index b5809f46..cd697218 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Settings, Sun } from "lucide-react"; -import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; +import { Link, Outlet, useLocation, useNavigate, useNavigationType, useParams } from "@/lib/router"; import { CompanyRail } from "./CompanyRail"; import { Sidebar } from "./Sidebar"; import { InstanceSidebar } from "./InstanceSidebar"; @@ -32,6 +32,11 @@ import { DEFAULT_INSTANCE_SETTINGS_PATH, normalizeRememberedInstanceSettingsPath, } from "../lib/instance-settings"; +import { + resetNavigationScroll, + SIDEBAR_SCROLL_RESET_STATE, + shouldResetScrollOnNavigation, +} from "../lib/navigation-scroll"; import { queryKeys } from "../lib/queryKeys"; import { scheduleMainContentFocus } from "../lib/main-content-focus"; import { cn } from "../lib/utils"; @@ -66,9 +71,12 @@ export function Layout() { const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); const location = useLocation(); + const navigationType = useNavigationType(); const isInstanceSettingsRoute = location.pathname.startsWith("/instance/"); 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); @@ -146,12 +154,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), @@ -271,10 +288,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 (
{ @@ -392,6 +424,7 @@ export function Layout() {
- {/* Title */} -
-